@alfe.ai/browser 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/index.cjs +203 -25
- package/dist/index.d.cts +34 -5
- package/dist/index.d.ts +34 -5
- package/dist/index.js +203 -25
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -17,5 +17,13 @@ and human-clicked navigation. The package bounds viewports, screencast frames,
|
|
|
17
17
|
automation input/results, and keeps replacement Chrome/CDP generations from
|
|
18
18
|
mutating current state.
|
|
19
19
|
|
|
20
|
+
## Browser integration verification
|
|
21
|
+
|
|
22
|
+
Run `BROWSER_TEST_EXECUTABLE_PATH=/path/to/chrome pnpm test:integration` from
|
|
23
|
+
this package. This dedicated suite launches a real headless Chrome with a fresh
|
|
24
|
+
temporary profile and local HTTP/WebSocket fixtures. It exercises streamed
|
|
25
|
+
JPEGs, human keyboard/mouse input, login popups, hand-back, and cookie persistence
|
|
26
|
+
after browser restart; it never attaches to an existing browser profile.
|
|
27
|
+
|
|
20
28
|
Part of [Alfe](https://alfe.ai). See the
|
|
21
29
|
[documentation](https://docs.alfe.ai) for platform setup.
|
package/dist/index.cjs
CHANGED
|
@@ -242,6 +242,15 @@ var BrowserSession = class {
|
|
|
242
242
|
});
|
|
243
243
|
}).catch(() => {});
|
|
244
244
|
});
|
|
245
|
+
browser.on("targetdestroyed", (target) => {
|
|
246
|
+
const closedPage = this.activePage;
|
|
247
|
+
if (this.browser !== browser || !closedPage || target.type() !== puppeteer_core.TargetType.PAGE) return;
|
|
248
|
+
target.page().then(async (page) => {
|
|
249
|
+
if (page === closedPage && this.browser === browser && this.activePage === closedPage) await this.restoreAfterClose(browser, closedPage, target.opener());
|
|
250
|
+
}).catch(() => {
|
|
251
|
+
this.log.warn("Could not restore the browser page after a popup closed");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
245
254
|
} catch (error) {
|
|
246
255
|
await browser.close().catch(() => void 0);
|
|
247
256
|
throw error;
|
|
@@ -259,6 +268,27 @@ var BrowserSession = class {
|
|
|
259
268
|
this.log.debug("Active page switched to new target");
|
|
260
269
|
return true;
|
|
261
270
|
}
|
|
271
|
+
/** A login popup can close while the agent is parked for human control.
|
|
272
|
+
* Recover here, without waiting for another automation call to discover
|
|
273
|
+
* that the active page is closed, and rebind the viewer to its opener. */
|
|
274
|
+
async restoreAfterClose(browser, closedPage, openerTarget) {
|
|
275
|
+
let opener = await openerTarget?.page();
|
|
276
|
+
while (this.browser === browser && this.activePage === closedPage) {
|
|
277
|
+
const pages = await browser.pages();
|
|
278
|
+
if (this.browser !== browser || this.activePage !== closedPage) return;
|
|
279
|
+
const page = opener && !opener.isClosed() ? opener : pages.find((candidate) => !candidate.isClosed() && isNavigablePageUrl(candidate.url())) ?? pages.find((candidate) => !candidate.isClosed()) ?? await browser.newPage();
|
|
280
|
+
opener = null;
|
|
281
|
+
try {
|
|
282
|
+
await this.preparePage(page);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (page.isClosed()) continue;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
if (page.isClosed()) continue;
|
|
288
|
+
if (this.browser === browser && this.activePage === closedPage) this.setActivePage(page);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
262
292
|
/** The current active page, launching Chrome first if needed. */
|
|
263
293
|
async getActivePage() {
|
|
264
294
|
await this.ensureLaunched();
|
|
@@ -278,6 +308,55 @@ var BrowserSession = class {
|
|
|
278
308
|
this.activePageListeners.delete(listener);
|
|
279
309
|
};
|
|
280
310
|
}
|
|
311
|
+
/** Internal to the serialized automation turn. The caller holds Chrome until
|
|
312
|
+
* the adapter has disconnected its client/awaited its child process exit. */
|
|
313
|
+
async withCdpTarget(operation, signal) {
|
|
314
|
+
signal.throwIfAborted();
|
|
315
|
+
const page = await this.getActivePage();
|
|
316
|
+
const browser = this.browser;
|
|
317
|
+
const generation = this.generation;
|
|
318
|
+
if (!browser) throw new Error("Browser is unavailable");
|
|
319
|
+
const disconnected = new AbortController();
|
|
320
|
+
const abort = () => {
|
|
321
|
+
disconnected.abort(/* @__PURE__ */ new Error("Browser operation interrupted"));
|
|
322
|
+
};
|
|
323
|
+
const operationSignal = AbortSignal.any([signal, disconnected.signal]);
|
|
324
|
+
const assertCurrent = () => {
|
|
325
|
+
operationSignal.throwIfAborted();
|
|
326
|
+
if (this.browser !== browser || this.generation !== generation || !browser.connected || page.isClosed()) throw new Error("Browser operation interrupted");
|
|
327
|
+
};
|
|
328
|
+
browser.on("disconnected", abort);
|
|
329
|
+
page.on("close", abort);
|
|
330
|
+
try {
|
|
331
|
+
assertCurrent();
|
|
332
|
+
const cdp = await page.createCDPSession();
|
|
333
|
+
let targetId;
|
|
334
|
+
try {
|
|
335
|
+
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
|
336
|
+
targetId = targetInfo.targetId;
|
|
337
|
+
} finally {
|
|
338
|
+
await cdp.detach().catch(() => void 0);
|
|
339
|
+
}
|
|
340
|
+
assertCurrent();
|
|
341
|
+
const browserWSEndpoint = browser.wsEndpoint();
|
|
342
|
+
const endpoint = new URL(browserWSEndpoint);
|
|
343
|
+
if (endpoint.protocol !== "ws:" || ![
|
|
344
|
+
"127.0.0.1",
|
|
345
|
+
"[::1]",
|
|
346
|
+
"localhost"
|
|
347
|
+
].includes(endpoint.hostname) || endpoint.username !== "" || endpoint.password !== "" || !endpoint.pathname.startsWith("/devtools/browser/")) throw new Error("Browser local attachment is unavailable");
|
|
348
|
+
const result = await operation({
|
|
349
|
+
browserWSEndpoint,
|
|
350
|
+
targetId,
|
|
351
|
+
signal: operationSignal
|
|
352
|
+
});
|
|
353
|
+
assertCurrent();
|
|
354
|
+
return result;
|
|
355
|
+
} finally {
|
|
356
|
+
browser.off("disconnected", abort);
|
|
357
|
+
page.off("close", abort);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
281
360
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
282
361
|
addHold() {
|
|
283
362
|
this.holds += 1;
|
|
@@ -505,6 +584,43 @@ const MOUSE_TYPE = {
|
|
|
505
584
|
mousepressed: "mousePressed",
|
|
506
585
|
mousereleased: "mouseReleased"
|
|
507
586
|
};
|
|
587
|
+
const VIRTUAL_KEYS = {
|
|
588
|
+
Backspace: 8,
|
|
589
|
+
Tab: 9,
|
|
590
|
+
Enter: 13,
|
|
591
|
+
NumpadEnter: 13,
|
|
592
|
+
Shift: 16,
|
|
593
|
+
Control: 17,
|
|
594
|
+
Alt: 18,
|
|
595
|
+
Pause: 19,
|
|
596
|
+
CapsLock: 20,
|
|
597
|
+
Escape: 27,
|
|
598
|
+
Space: 32,
|
|
599
|
+
" ": 32,
|
|
600
|
+
PageUp: 33,
|
|
601
|
+
PageDown: 34,
|
|
602
|
+
End: 35,
|
|
603
|
+
Home: 36,
|
|
604
|
+
ArrowLeft: 37,
|
|
605
|
+
ArrowUp: 38,
|
|
606
|
+
ArrowRight: 39,
|
|
607
|
+
ArrowDown: 40,
|
|
608
|
+
Insert: 45,
|
|
609
|
+
Delete: 46,
|
|
610
|
+
Meta: 91
|
|
611
|
+
};
|
|
612
|
+
function virtualKeyCode(input) {
|
|
613
|
+
const named = VIRTUAL_KEYS[input.key ?? ""] ?? VIRTUAL_KEYS[input.code ?? ""];
|
|
614
|
+
if (typeof named === "number") return named;
|
|
615
|
+
const letter = /^Key([A-Z])$/.exec(input.code ?? "");
|
|
616
|
+
if (letter) return letter[1].charCodeAt(0);
|
|
617
|
+
const digit = /^Digit([0-9])$/.exec(input.code ?? "");
|
|
618
|
+
if (digit) return digit[1].charCodeAt(0);
|
|
619
|
+
const key = input.key ?? "";
|
|
620
|
+
if (/^[A-Za-z0-9]$/.test(key)) return key.toUpperCase().charCodeAt(0);
|
|
621
|
+
const functionKey = /^F([1-9]|1[0-9]|2[0-4])$/.exec(input.key ?? "");
|
|
622
|
+
if (functionKey) return 111 + Number(functionKey[1]);
|
|
623
|
+
}
|
|
508
624
|
var InputInjector = class {
|
|
509
625
|
cdp = null;
|
|
510
626
|
viewport;
|
|
@@ -569,19 +685,18 @@ var InputInjector = class {
|
|
|
569
685
|
key(p) {
|
|
570
686
|
if (!this.cdp) return;
|
|
571
687
|
if (p.type === "char") {
|
|
572
|
-
this.cdp.send("Input.
|
|
573
|
-
type: "char",
|
|
574
|
-
text: p.text ?? "",
|
|
575
|
-
modifiers: p.modifiers ?? 0
|
|
576
|
-
}).catch(() => {});
|
|
688
|
+
this.cdp.send("Input.insertText", { text: p.text ?? "" }).catch(() => {});
|
|
577
689
|
return;
|
|
578
690
|
}
|
|
691
|
+
const modifiers = p.modifiers ?? 0;
|
|
692
|
+
const text = p.type === "keydown" && !(modifiers & 7) ? p.text ?? (p.key === "Enter" ? "\r" : void 0) : void 0;
|
|
579
693
|
this.cdp.send("Input.dispatchKeyEvent", {
|
|
580
|
-
type: p.type === "
|
|
694
|
+
type: p.type === "keyup" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
|
|
581
695
|
key: p.key,
|
|
582
696
|
code: p.code,
|
|
583
|
-
text
|
|
584
|
-
|
|
697
|
+
text,
|
|
698
|
+
windowsVirtualKeyCode: virtualKeyCode(p),
|
|
699
|
+
modifiers
|
|
585
700
|
}).catch(() => {});
|
|
586
701
|
}
|
|
587
702
|
};
|
|
@@ -594,6 +709,7 @@ function clamp01(n) {
|
|
|
594
709
|
const OPERATION_TIMEOUT_MS = 3e4;
|
|
595
710
|
var BrowserAutomation = class {
|
|
596
711
|
operationTail = Promise.resolve();
|
|
712
|
+
stopping = new AbortController();
|
|
597
713
|
constructor(session, turn, isNavigationAllowed) {
|
|
598
714
|
this.session = session;
|
|
599
715
|
this.turn = turn;
|
|
@@ -677,13 +793,38 @@ var BrowserAutomation = class {
|
|
|
677
793
|
async waitUntilIdle() {
|
|
678
794
|
await this.operationTail;
|
|
679
795
|
}
|
|
680
|
-
|
|
796
|
+
/** Trusted local adapters share the built-in automation queue and exact page.
|
|
797
|
+
* The callback must disconnect/await children in finally, including on abort.
|
|
798
|
+
* Do not invoke another automation operation or handoff from the callback. */
|
|
799
|
+
withCdpOperation(operation, options = {}) {
|
|
800
|
+
return this.run((signal) => this.session.withCdpTarget(operation, signal), options);
|
|
801
|
+
}
|
|
802
|
+
/** Insert the claim at a precise queue position; later automation parks. */
|
|
803
|
+
async yieldToHuman(grant) {
|
|
804
|
+
await this.run(() => {
|
|
805
|
+
grant();
|
|
806
|
+
return Promise.resolve();
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/** Abort first, then await callback cleanup before the owner closes Chrome. */
|
|
810
|
+
async shutdown() {
|
|
811
|
+
this.stopping.abort(/* @__PURE__ */ new Error("Browser automation stopped"));
|
|
812
|
+
this.turn.releaseHuman();
|
|
813
|
+
await this.operationTail;
|
|
814
|
+
}
|
|
815
|
+
run(operation, options = {}) {
|
|
816
|
+
const signal = options.signal ? AbortSignal.any([this.stopping.signal, options.signal]) : this.stopping.signal;
|
|
681
817
|
const result = this.operationTail.catch(() => void 0).then(async () => {
|
|
682
|
-
|
|
818
|
+
signal.throwIfAborted();
|
|
819
|
+
this.session.addHold();
|
|
683
820
|
try {
|
|
684
|
-
|
|
821
|
+
await this.turn.acquireAgent(signal);
|
|
822
|
+
signal.throwIfAborted();
|
|
823
|
+
const result = await operation(signal);
|
|
824
|
+
signal.throwIfAborted();
|
|
825
|
+
return result;
|
|
685
826
|
} finally {
|
|
686
|
-
this.session.
|
|
827
|
+
this.session.removeHold();
|
|
687
828
|
}
|
|
688
829
|
});
|
|
689
830
|
this.operationTail = result.then(() => void 0, () => void 0);
|
|
@@ -724,13 +865,13 @@ var BrowserSurface = class {
|
|
|
724
865
|
handoff = null;
|
|
725
866
|
controllerSessionId = null;
|
|
726
867
|
pendingControllerSessionId = null;
|
|
868
|
+
claimGeneration = 0;
|
|
727
869
|
streamGeneration = 0;
|
|
728
870
|
streamQueue = Promise.resolve();
|
|
729
871
|
closed = false;
|
|
730
872
|
removePageListener;
|
|
731
873
|
log;
|
|
732
874
|
constructor(options, sendFrame) {
|
|
733
|
-
this.options = options;
|
|
734
875
|
this.sendFrame = sendFrame;
|
|
735
876
|
this.log = options.logger ?? noopLogger;
|
|
736
877
|
const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
|
|
@@ -756,8 +897,11 @@ var BrowserSurface = class {
|
|
|
756
897
|
});
|
|
757
898
|
this.injector = new InputInjector(this.viewport);
|
|
758
899
|
this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
|
|
759
|
-
this.removePageListener = this.session.onActivePageChange(() => {
|
|
760
|
-
if (this.viewers.size > 0)
|
|
900
|
+
this.removePageListener = this.session.onActivePageChange(async () => {
|
|
901
|
+
if (this.viewers.size > 0) {
|
|
902
|
+
await this.restartStreaming();
|
|
903
|
+
this.broadcastState();
|
|
904
|
+
}
|
|
761
905
|
});
|
|
762
906
|
}
|
|
763
907
|
async openSession(sessionId, open) {
|
|
@@ -815,25 +959,28 @@ var BrowserSurface = class {
|
|
|
815
959
|
if (input) this.injector.key(input);
|
|
816
960
|
}
|
|
817
961
|
break;
|
|
818
|
-
case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST:
|
|
962
|
+
case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST: {
|
|
819
963
|
if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
|
|
820
964
|
this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
821
965
|
break;
|
|
822
966
|
}
|
|
823
967
|
this.pendingControllerSessionId = frame.sessionId;
|
|
824
|
-
this.
|
|
825
|
-
|
|
968
|
+
const claimGeneration = ++this.claimGeneration;
|
|
969
|
+
this.automation.yieldToHuman(() => {
|
|
970
|
+
if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
|
|
826
971
|
this.pendingControllerSessionId = null;
|
|
827
972
|
if (this.turn.grantHuman()) {
|
|
828
973
|
this.controllerSessionId = frame.sessionId;
|
|
829
974
|
this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
|
|
830
975
|
} else this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
831
976
|
}).catch((error) => {
|
|
832
|
-
if (this.pendingControllerSessionId
|
|
977
|
+
if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId) return;
|
|
978
|
+
this.pendingControllerSessionId = null;
|
|
833
979
|
this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
|
|
834
980
|
this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
835
981
|
});
|
|
836
982
|
break;
|
|
983
|
+
}
|
|
837
984
|
case _alfe_ai_remote.RemoteFrameType.RELEASE_CONTROL:
|
|
838
985
|
if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
|
|
839
986
|
break;
|
|
@@ -843,7 +990,10 @@ var BrowserSurface = class {
|
|
|
843
990
|
closeSession(sessionId) {
|
|
844
991
|
if (!this.viewers.delete(sessionId)) return;
|
|
845
992
|
this.session.removeHold();
|
|
846
|
-
if (this.pendingControllerSessionId === sessionId)
|
|
993
|
+
if (this.pendingControllerSessionId === sessionId) {
|
|
994
|
+
this.pendingControllerSessionId = null;
|
|
995
|
+
this.claimGeneration += 1;
|
|
996
|
+
}
|
|
847
997
|
if (this.controllerSessionId === sessionId) this.releaseToAgent();
|
|
848
998
|
if (this.viewers.size === 0) {
|
|
849
999
|
this.streamGeneration += 1;
|
|
@@ -865,20 +1015,28 @@ var BrowserSurface = class {
|
|
|
865
1015
|
* under the user (mislabeled 409 on the real claim; agent only resuming when
|
|
866
1016
|
* the tab closed).
|
|
867
1017
|
*/
|
|
868
|
-
async requestHandoff(timeoutMs) {
|
|
1018
|
+
async requestHandoff(timeoutMs, signal) {
|
|
869
1019
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
|
|
1020
|
+
if (this.closed) throw new Error("Browser surface is shut down");
|
|
1021
|
+
signal?.throwIfAborted();
|
|
870
1022
|
if (this.handoff) this.releaseToAgent();
|
|
871
|
-
return new Promise((resolve) => {
|
|
1023
|
+
return new Promise((resolve, reject) => {
|
|
1024
|
+
const settle = (result) => {
|
|
1025
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1026
|
+
resolve(result);
|
|
1027
|
+
};
|
|
872
1028
|
const timer = setTimeout(() => {
|
|
1029
|
+
if (this.handoff !== waiter) return;
|
|
873
1030
|
this.handoff = null;
|
|
874
1031
|
this.pendingControllerSessionId = null;
|
|
1032
|
+
this.claimGeneration += 1;
|
|
875
1033
|
if (this.turn.humanInControl) {
|
|
876
1034
|
this.turn.releaseHuman();
|
|
877
1035
|
this.controllerSessionId = null;
|
|
878
1036
|
this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
|
|
879
1037
|
}
|
|
880
1038
|
this.currentPageInfo().then(({ url, title }) => {
|
|
881
|
-
|
|
1039
|
+
settle({
|
|
882
1040
|
released: false,
|
|
883
1041
|
timedOut: true,
|
|
884
1042
|
url,
|
|
@@ -887,10 +1045,27 @@ var BrowserSurface = class {
|
|
|
887
1045
|
});
|
|
888
1046
|
}, timeoutMs);
|
|
889
1047
|
timer.unref();
|
|
890
|
-
|
|
891
|
-
resolve,
|
|
1048
|
+
const waiter = {
|
|
1049
|
+
resolve: settle,
|
|
892
1050
|
timer
|
|
893
1051
|
};
|
|
1052
|
+
const onAbort = () => {
|
|
1053
|
+
if (this.handoff !== waiter) return;
|
|
1054
|
+
this.handoff = null;
|
|
1055
|
+
clearTimeout(timer);
|
|
1056
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1057
|
+
this.pendingControllerSessionId = null;
|
|
1058
|
+
this.claimGeneration += 1;
|
|
1059
|
+
if (this.turn.humanInControl) {
|
|
1060
|
+
this.turn.releaseHuman();
|
|
1061
|
+
this.controllerSessionId = null;
|
|
1062
|
+
this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
|
|
1063
|
+
}
|
|
1064
|
+
reject(/* @__PURE__ */ new Error("Browser handoff cancelled"));
|
|
1065
|
+
};
|
|
1066
|
+
this.handoff = waiter;
|
|
1067
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1068
|
+
if (signal?.aborted) onAbort();
|
|
894
1069
|
});
|
|
895
1070
|
}
|
|
896
1071
|
/**
|
|
@@ -911,6 +1086,7 @@ var BrowserSurface = class {
|
|
|
911
1086
|
this.removePageListener();
|
|
912
1087
|
this.streamGeneration += 1;
|
|
913
1088
|
this.pendingControllerSessionId = null;
|
|
1089
|
+
this.claimGeneration += 1;
|
|
914
1090
|
this.controllerSessionId = null;
|
|
915
1091
|
const waiter = this.handoff;
|
|
916
1092
|
this.handoff = null;
|
|
@@ -924,11 +1100,13 @@ var BrowserSurface = class {
|
|
|
924
1100
|
});
|
|
925
1101
|
}
|
|
926
1102
|
this.turn.releaseHuman();
|
|
1103
|
+
await this.automation.shutdown();
|
|
927
1104
|
await this.enqueueStreamCleanup();
|
|
928
1105
|
await this.session.shutdown();
|
|
929
1106
|
}
|
|
930
1107
|
releaseToAgent() {
|
|
931
1108
|
this.pendingControllerSessionId = null;
|
|
1109
|
+
this.claimGeneration += 1;
|
|
932
1110
|
this.controllerSessionId = null;
|
|
933
1111
|
this.turn.releaseHuman();
|
|
934
1112
|
const waiter = this.handoff;
|
package/dist/index.d.cts
CHANGED
|
@@ -96,9 +96,10 @@ declare class TurnController {
|
|
|
96
96
|
/**
|
|
97
97
|
* Resolve once the agent is (again) allowed to drive. Resolves immediately if
|
|
98
98
|
* the agent already holds the token; otherwise parks until `releaseHuman()`.
|
|
99
|
-
* Every automation op awaits this before touching the page.
|
|
99
|
+
* Every automation op awaits this before touching the page. Cancellation
|
|
100
|
+
* removes only this waiter and never changes the current human owner.
|
|
100
101
|
*/
|
|
101
|
-
acquireAgent(): Promise<void>;
|
|
102
|
+
acquireAgent(signal?: AbortSignal): Promise<void>;
|
|
102
103
|
/**
|
|
103
104
|
* Grant control to a human. Succeeds unless a human already holds it (single
|
|
104
105
|
* controller). "Human request always wins over the agent" — the agent parks
|
|
@@ -112,6 +113,18 @@ declare class TurnController {
|
|
|
112
113
|
//#endregion
|
|
113
114
|
//#endregion
|
|
114
115
|
//#region src/types.d.ts
|
|
116
|
+
/** Private local capability for a trusted adapter, never a tool result. */
|
|
117
|
+
interface BrowserOperationContext {
|
|
118
|
+
/** Loopback Chrome endpoint. Do not log, persist, or expose to a viewer/model. */
|
|
119
|
+
browserWSEndpoint: string;
|
|
120
|
+
/** The exact active page's CDP target; never choose the first context/page. */
|
|
121
|
+
targetId: string;
|
|
122
|
+
/** On abort, stop and await all external work before returning. */
|
|
123
|
+
signal: AbortSignal;
|
|
124
|
+
}
|
|
125
|
+
interface BrowserOperationOptions {
|
|
126
|
+
signal?: AbortSignal;
|
|
127
|
+
}
|
|
115
128
|
interface BrowserSessionOptions {
|
|
116
129
|
/** Path to the Chrome/Chromium binary (from the headless-browser integration). */
|
|
117
130
|
executablePath: string;
|
|
@@ -154,9 +167,16 @@ declare class BrowserSession {
|
|
|
154
167
|
* screencast to a transient `about:blank` throwaway target.
|
|
155
168
|
*/
|
|
156
169
|
private adoptIfNavigable;
|
|
170
|
+
/** A login popup can close while the agent is parked for human control.
|
|
171
|
+
* Recover here, without waiting for another automation call to discover
|
|
172
|
+
* that the active page is closed, and rebind the viewer to its opener. */
|
|
173
|
+
private restoreAfterClose;
|
|
157
174
|
/** The current active page, launching Chrome first if needed. */
|
|
158
175
|
getActivePage(): Promise<Page>;
|
|
159
176
|
onActivePageChange(listener: (page: Page) => void | Promise<void>): () => void;
|
|
177
|
+
/** Internal to the serialized automation turn. The caller holds Chrome until
|
|
178
|
+
* the adapter has disconnected its client/awaited its child process exit. */
|
|
179
|
+
withCdpTarget<T>(operation: (context: BrowserOperationContext) => Promise<T>, signal: AbortSignal): Promise<T>;
|
|
160
180
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
161
181
|
addHold(): void;
|
|
162
182
|
/** Release a hold; arm idle shutdown when the last one is released. */
|
|
@@ -181,6 +201,7 @@ declare class BrowserAutomation {
|
|
|
181
201
|
private readonly turn;
|
|
182
202
|
private readonly isNavigationAllowed;
|
|
183
203
|
private operationTail;
|
|
204
|
+
private readonly stopping;
|
|
184
205
|
constructor(session: BrowserSession, turn: TurnController, isNavigationAllowed: (url: string) => boolean);
|
|
185
206
|
navigate(url: string): Promise<NavigateResult>;
|
|
186
207
|
click(selector: string): Promise<void>;
|
|
@@ -197,6 +218,14 @@ declare class BrowserAutomation {
|
|
|
197
218
|
evaluate(expression: string): Promise<unknown>;
|
|
198
219
|
/** Wait until all agent operations that were already queued have settled. */
|
|
199
220
|
waitUntilIdle(): Promise<void>;
|
|
221
|
+
/** Trusted local adapters share the built-in automation queue and exact page.
|
|
222
|
+
* The callback must disconnect/await children in finally, including on abort.
|
|
223
|
+
* Do not invoke another automation operation or handoff from the callback. */
|
|
224
|
+
withCdpOperation<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
|
|
225
|
+
/** Insert the claim at a precise queue position; later automation parks. */
|
|
226
|
+
yieldToHuman(grant: () => void): Promise<void>;
|
|
227
|
+
/** Abort first, then await callback cleanup before the owner closes Chrome. */
|
|
228
|
+
shutdown(): Promise<void>;
|
|
200
229
|
private run;
|
|
201
230
|
}
|
|
202
231
|
//#endregion
|
|
@@ -208,7 +237,6 @@ interface HandoffResult {
|
|
|
208
237
|
title: string;
|
|
209
238
|
}
|
|
210
239
|
declare class BrowserSurface implements SurfaceHandler {
|
|
211
|
-
private readonly options;
|
|
212
240
|
private readonly sendFrame;
|
|
213
241
|
readonly surface: "browser";
|
|
214
242
|
private readonly session;
|
|
@@ -221,6 +249,7 @@ declare class BrowserSurface implements SurfaceHandler {
|
|
|
221
249
|
private handoff;
|
|
222
250
|
private controllerSessionId;
|
|
223
251
|
private pendingControllerSessionId;
|
|
252
|
+
private claimGeneration;
|
|
224
253
|
private streamGeneration;
|
|
225
254
|
private streamQueue;
|
|
226
255
|
private closed;
|
|
@@ -245,7 +274,7 @@ declare class BrowserSurface implements SurfaceHandler {
|
|
|
245
274
|
* under the user (mislabeled 409 on the real claim; agent only resuming when
|
|
246
275
|
* the tab closed).
|
|
247
276
|
*/
|
|
248
|
-
requestHandoff(timeoutMs: number): Promise<HandoffResult>;
|
|
277
|
+
requestHandoff(timeoutMs: number, signal?: AbortSignal): Promise<HandoffResult>;
|
|
249
278
|
/**
|
|
250
279
|
* Keep the shared Chrome alive across an awaiting-human window. Delegates to
|
|
251
280
|
* the session's hold counter (which also backs per-viewer holds). Idempotent
|
|
@@ -268,4 +297,4 @@ declare class BrowserSurface implements SurfaceHandler {
|
|
|
268
297
|
private sendFrameSafe;
|
|
269
298
|
}
|
|
270
299
|
//#endregion
|
|
271
|
-
export { BrowserAutomation, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
|
|
300
|
+
export { BrowserAutomation, type BrowserOperationContext, type BrowserOperationOptions, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
|
package/dist/index.d.ts
CHANGED
|
@@ -96,9 +96,10 @@ declare class TurnController {
|
|
|
96
96
|
/**
|
|
97
97
|
* Resolve once the agent is (again) allowed to drive. Resolves immediately if
|
|
98
98
|
* the agent already holds the token; otherwise parks until `releaseHuman()`.
|
|
99
|
-
* Every automation op awaits this before touching the page.
|
|
99
|
+
* Every automation op awaits this before touching the page. Cancellation
|
|
100
|
+
* removes only this waiter and never changes the current human owner.
|
|
100
101
|
*/
|
|
101
|
-
acquireAgent(): Promise<void>;
|
|
102
|
+
acquireAgent(signal?: AbortSignal): Promise<void>;
|
|
102
103
|
/**
|
|
103
104
|
* Grant control to a human. Succeeds unless a human already holds it (single
|
|
104
105
|
* controller). "Human request always wins over the agent" — the agent parks
|
|
@@ -112,6 +113,18 @@ declare class TurnController {
|
|
|
112
113
|
//#endregion
|
|
113
114
|
//#endregion
|
|
114
115
|
//#region src/types.d.ts
|
|
116
|
+
/** Private local capability for a trusted adapter, never a tool result. */
|
|
117
|
+
interface BrowserOperationContext {
|
|
118
|
+
/** Loopback Chrome endpoint. Do not log, persist, or expose to a viewer/model. */
|
|
119
|
+
browserWSEndpoint: string;
|
|
120
|
+
/** The exact active page's CDP target; never choose the first context/page. */
|
|
121
|
+
targetId: string;
|
|
122
|
+
/** On abort, stop and await all external work before returning. */
|
|
123
|
+
signal: AbortSignal;
|
|
124
|
+
}
|
|
125
|
+
interface BrowserOperationOptions {
|
|
126
|
+
signal?: AbortSignal;
|
|
127
|
+
}
|
|
115
128
|
interface BrowserSessionOptions {
|
|
116
129
|
/** Path to the Chrome/Chromium binary (from the headless-browser integration). */
|
|
117
130
|
executablePath: string;
|
|
@@ -154,9 +167,16 @@ declare class BrowserSession {
|
|
|
154
167
|
* screencast to a transient `about:blank` throwaway target.
|
|
155
168
|
*/
|
|
156
169
|
private adoptIfNavigable;
|
|
170
|
+
/** A login popup can close while the agent is parked for human control.
|
|
171
|
+
* Recover here, without waiting for another automation call to discover
|
|
172
|
+
* that the active page is closed, and rebind the viewer to its opener. */
|
|
173
|
+
private restoreAfterClose;
|
|
157
174
|
/** The current active page, launching Chrome first if needed. */
|
|
158
175
|
getActivePage(): Promise<Page>;
|
|
159
176
|
onActivePageChange(listener: (page: Page) => void | Promise<void>): () => void;
|
|
177
|
+
/** Internal to the serialized automation turn. The caller holds Chrome until
|
|
178
|
+
* the adapter has disconnected its client/awaited its child process exit. */
|
|
179
|
+
withCdpTarget<T>(operation: (context: BrowserOperationContext) => Promise<T>, signal: AbortSignal): Promise<T>;
|
|
160
180
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
161
181
|
addHold(): void;
|
|
162
182
|
/** Release a hold; arm idle shutdown when the last one is released. */
|
|
@@ -181,6 +201,7 @@ declare class BrowserAutomation {
|
|
|
181
201
|
private readonly turn;
|
|
182
202
|
private readonly isNavigationAllowed;
|
|
183
203
|
private operationTail;
|
|
204
|
+
private readonly stopping;
|
|
184
205
|
constructor(session: BrowserSession, turn: TurnController, isNavigationAllowed: (url: string) => boolean);
|
|
185
206
|
navigate(url: string): Promise<NavigateResult>;
|
|
186
207
|
click(selector: string): Promise<void>;
|
|
@@ -197,6 +218,14 @@ declare class BrowserAutomation {
|
|
|
197
218
|
evaluate(expression: string): Promise<unknown>;
|
|
198
219
|
/** Wait until all agent operations that were already queued have settled. */
|
|
199
220
|
waitUntilIdle(): Promise<void>;
|
|
221
|
+
/** Trusted local adapters share the built-in automation queue and exact page.
|
|
222
|
+
* The callback must disconnect/await children in finally, including on abort.
|
|
223
|
+
* Do not invoke another automation operation or handoff from the callback. */
|
|
224
|
+
withCdpOperation<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
|
|
225
|
+
/** Insert the claim at a precise queue position; later automation parks. */
|
|
226
|
+
yieldToHuman(grant: () => void): Promise<void>;
|
|
227
|
+
/** Abort first, then await callback cleanup before the owner closes Chrome. */
|
|
228
|
+
shutdown(): Promise<void>;
|
|
200
229
|
private run;
|
|
201
230
|
}
|
|
202
231
|
//#endregion
|
|
@@ -208,7 +237,6 @@ interface HandoffResult {
|
|
|
208
237
|
title: string;
|
|
209
238
|
}
|
|
210
239
|
declare class BrowserSurface implements SurfaceHandler {
|
|
211
|
-
private readonly options;
|
|
212
240
|
private readonly sendFrame;
|
|
213
241
|
readonly surface: "browser";
|
|
214
242
|
private readonly session;
|
|
@@ -221,6 +249,7 @@ declare class BrowserSurface implements SurfaceHandler {
|
|
|
221
249
|
private handoff;
|
|
222
250
|
private controllerSessionId;
|
|
223
251
|
private pendingControllerSessionId;
|
|
252
|
+
private claimGeneration;
|
|
224
253
|
private streamGeneration;
|
|
225
254
|
private streamQueue;
|
|
226
255
|
private closed;
|
|
@@ -245,7 +274,7 @@ declare class BrowserSurface implements SurfaceHandler {
|
|
|
245
274
|
* under the user (mislabeled 409 on the real claim; agent only resuming when
|
|
246
275
|
* the tab closed).
|
|
247
276
|
*/
|
|
248
|
-
requestHandoff(timeoutMs: number): Promise<HandoffResult>;
|
|
277
|
+
requestHandoff(timeoutMs: number, signal?: AbortSignal): Promise<HandoffResult>;
|
|
249
278
|
/**
|
|
250
279
|
* Keep the shared Chrome alive across an awaiting-human window. Delegates to
|
|
251
280
|
* the session's hold counter (which also backs per-viewer holds). Idempotent
|
|
@@ -268,4 +297,4 @@ declare class BrowserSurface implements SurfaceHandler {
|
|
|
268
297
|
private sendFrameSafe;
|
|
269
298
|
}
|
|
270
299
|
//#endregion
|
|
271
|
-
export { BrowserAutomation, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
|
|
300
|
+
export { BrowserAutomation, type BrowserOperationContext, type BrowserOperationOptions, BrowserSession, type BrowserSessionOptions, BrowserSurface, type BrowserSurfaceOptions, type HandoffResult, type Logger, type NavigateResult };
|
package/dist/index.js
CHANGED
|
@@ -218,6 +218,15 @@ var BrowserSession = class {
|
|
|
218
218
|
});
|
|
219
219
|
}).catch(() => {});
|
|
220
220
|
});
|
|
221
|
+
browser.on("targetdestroyed", (target) => {
|
|
222
|
+
const closedPage = this.activePage;
|
|
223
|
+
if (this.browser !== browser || !closedPage || target.type() !== TargetType.PAGE) return;
|
|
224
|
+
target.page().then(async (page) => {
|
|
225
|
+
if (page === closedPage && this.browser === browser && this.activePage === closedPage) await this.restoreAfterClose(browser, closedPage, target.opener());
|
|
226
|
+
}).catch(() => {
|
|
227
|
+
this.log.warn("Could not restore the browser page after a popup closed");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
221
230
|
} catch (error) {
|
|
222
231
|
await browser.close().catch(() => void 0);
|
|
223
232
|
throw error;
|
|
@@ -235,6 +244,27 @@ var BrowserSession = class {
|
|
|
235
244
|
this.log.debug("Active page switched to new target");
|
|
236
245
|
return true;
|
|
237
246
|
}
|
|
247
|
+
/** A login popup can close while the agent is parked for human control.
|
|
248
|
+
* Recover here, without waiting for another automation call to discover
|
|
249
|
+
* that the active page is closed, and rebind the viewer to its opener. */
|
|
250
|
+
async restoreAfterClose(browser, closedPage, openerTarget) {
|
|
251
|
+
let opener = await openerTarget?.page();
|
|
252
|
+
while (this.browser === browser && this.activePage === closedPage) {
|
|
253
|
+
const pages = await browser.pages();
|
|
254
|
+
if (this.browser !== browser || this.activePage !== closedPage) return;
|
|
255
|
+
const page = opener && !opener.isClosed() ? opener : pages.find((candidate) => !candidate.isClosed() && isNavigablePageUrl(candidate.url())) ?? pages.find((candidate) => !candidate.isClosed()) ?? await browser.newPage();
|
|
256
|
+
opener = null;
|
|
257
|
+
try {
|
|
258
|
+
await this.preparePage(page);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (page.isClosed()) continue;
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (page.isClosed()) continue;
|
|
264
|
+
if (this.browser === browser && this.activePage === closedPage) this.setActivePage(page);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
238
268
|
/** The current active page, launching Chrome first if needed. */
|
|
239
269
|
async getActivePage() {
|
|
240
270
|
await this.ensureLaunched();
|
|
@@ -254,6 +284,55 @@ var BrowserSession = class {
|
|
|
254
284
|
this.activePageListeners.delete(listener);
|
|
255
285
|
};
|
|
256
286
|
}
|
|
287
|
+
/** Internal to the serialized automation turn. The caller holds Chrome until
|
|
288
|
+
* the adapter has disconnected its client/awaited its child process exit. */
|
|
289
|
+
async withCdpTarget(operation, signal) {
|
|
290
|
+
signal.throwIfAborted();
|
|
291
|
+
const page = await this.getActivePage();
|
|
292
|
+
const browser = this.browser;
|
|
293
|
+
const generation = this.generation;
|
|
294
|
+
if (!browser) throw new Error("Browser is unavailable");
|
|
295
|
+
const disconnected = new AbortController();
|
|
296
|
+
const abort = () => {
|
|
297
|
+
disconnected.abort(/* @__PURE__ */ new Error("Browser operation interrupted"));
|
|
298
|
+
};
|
|
299
|
+
const operationSignal = AbortSignal.any([signal, disconnected.signal]);
|
|
300
|
+
const assertCurrent = () => {
|
|
301
|
+
operationSignal.throwIfAborted();
|
|
302
|
+
if (this.browser !== browser || this.generation !== generation || !browser.connected || page.isClosed()) throw new Error("Browser operation interrupted");
|
|
303
|
+
};
|
|
304
|
+
browser.on("disconnected", abort);
|
|
305
|
+
page.on("close", abort);
|
|
306
|
+
try {
|
|
307
|
+
assertCurrent();
|
|
308
|
+
const cdp = await page.createCDPSession();
|
|
309
|
+
let targetId;
|
|
310
|
+
try {
|
|
311
|
+
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
|
312
|
+
targetId = targetInfo.targetId;
|
|
313
|
+
} finally {
|
|
314
|
+
await cdp.detach().catch(() => void 0);
|
|
315
|
+
}
|
|
316
|
+
assertCurrent();
|
|
317
|
+
const browserWSEndpoint = browser.wsEndpoint();
|
|
318
|
+
const endpoint = new URL(browserWSEndpoint);
|
|
319
|
+
if (endpoint.protocol !== "ws:" || ![
|
|
320
|
+
"127.0.0.1",
|
|
321
|
+
"[::1]",
|
|
322
|
+
"localhost"
|
|
323
|
+
].includes(endpoint.hostname) || endpoint.username !== "" || endpoint.password !== "" || !endpoint.pathname.startsWith("/devtools/browser/")) throw new Error("Browser local attachment is unavailable");
|
|
324
|
+
const result = await operation({
|
|
325
|
+
browserWSEndpoint,
|
|
326
|
+
targetId,
|
|
327
|
+
signal: operationSignal
|
|
328
|
+
});
|
|
329
|
+
assertCurrent();
|
|
330
|
+
return result;
|
|
331
|
+
} finally {
|
|
332
|
+
browser.off("disconnected", abort);
|
|
333
|
+
page.off("close", abort);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
257
336
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
258
337
|
addHold() {
|
|
259
338
|
this.holds += 1;
|
|
@@ -481,6 +560,43 @@ const MOUSE_TYPE = {
|
|
|
481
560
|
mousepressed: "mousePressed",
|
|
482
561
|
mousereleased: "mouseReleased"
|
|
483
562
|
};
|
|
563
|
+
const VIRTUAL_KEYS = {
|
|
564
|
+
Backspace: 8,
|
|
565
|
+
Tab: 9,
|
|
566
|
+
Enter: 13,
|
|
567
|
+
NumpadEnter: 13,
|
|
568
|
+
Shift: 16,
|
|
569
|
+
Control: 17,
|
|
570
|
+
Alt: 18,
|
|
571
|
+
Pause: 19,
|
|
572
|
+
CapsLock: 20,
|
|
573
|
+
Escape: 27,
|
|
574
|
+
Space: 32,
|
|
575
|
+
" ": 32,
|
|
576
|
+
PageUp: 33,
|
|
577
|
+
PageDown: 34,
|
|
578
|
+
End: 35,
|
|
579
|
+
Home: 36,
|
|
580
|
+
ArrowLeft: 37,
|
|
581
|
+
ArrowUp: 38,
|
|
582
|
+
ArrowRight: 39,
|
|
583
|
+
ArrowDown: 40,
|
|
584
|
+
Insert: 45,
|
|
585
|
+
Delete: 46,
|
|
586
|
+
Meta: 91
|
|
587
|
+
};
|
|
588
|
+
function virtualKeyCode(input) {
|
|
589
|
+
const named = VIRTUAL_KEYS[input.key ?? ""] ?? VIRTUAL_KEYS[input.code ?? ""];
|
|
590
|
+
if (typeof named === "number") return named;
|
|
591
|
+
const letter = /^Key([A-Z])$/.exec(input.code ?? "");
|
|
592
|
+
if (letter) return letter[1].charCodeAt(0);
|
|
593
|
+
const digit = /^Digit([0-9])$/.exec(input.code ?? "");
|
|
594
|
+
if (digit) return digit[1].charCodeAt(0);
|
|
595
|
+
const key = input.key ?? "";
|
|
596
|
+
if (/^[A-Za-z0-9]$/.test(key)) return key.toUpperCase().charCodeAt(0);
|
|
597
|
+
const functionKey = /^F([1-9]|1[0-9]|2[0-4])$/.exec(input.key ?? "");
|
|
598
|
+
if (functionKey) return 111 + Number(functionKey[1]);
|
|
599
|
+
}
|
|
484
600
|
var InputInjector = class {
|
|
485
601
|
cdp = null;
|
|
486
602
|
viewport;
|
|
@@ -545,19 +661,18 @@ var InputInjector = class {
|
|
|
545
661
|
key(p) {
|
|
546
662
|
if (!this.cdp) return;
|
|
547
663
|
if (p.type === "char") {
|
|
548
|
-
this.cdp.send("Input.
|
|
549
|
-
type: "char",
|
|
550
|
-
text: p.text ?? "",
|
|
551
|
-
modifiers: p.modifiers ?? 0
|
|
552
|
-
}).catch(() => {});
|
|
664
|
+
this.cdp.send("Input.insertText", { text: p.text ?? "" }).catch(() => {});
|
|
553
665
|
return;
|
|
554
666
|
}
|
|
667
|
+
const modifiers = p.modifiers ?? 0;
|
|
668
|
+
const text = p.type === "keydown" && !(modifiers & 7) ? p.text ?? (p.key === "Enter" ? "\r" : void 0) : void 0;
|
|
555
669
|
this.cdp.send("Input.dispatchKeyEvent", {
|
|
556
|
-
type: p.type === "
|
|
670
|
+
type: p.type === "keyup" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
|
|
557
671
|
key: p.key,
|
|
558
672
|
code: p.code,
|
|
559
|
-
text
|
|
560
|
-
|
|
673
|
+
text,
|
|
674
|
+
windowsVirtualKeyCode: virtualKeyCode(p),
|
|
675
|
+
modifiers
|
|
561
676
|
}).catch(() => {});
|
|
562
677
|
}
|
|
563
678
|
};
|
|
@@ -570,6 +685,7 @@ function clamp01(n) {
|
|
|
570
685
|
const OPERATION_TIMEOUT_MS = 3e4;
|
|
571
686
|
var BrowserAutomation = class {
|
|
572
687
|
operationTail = Promise.resolve();
|
|
688
|
+
stopping = new AbortController();
|
|
573
689
|
constructor(session, turn, isNavigationAllowed) {
|
|
574
690
|
this.session = session;
|
|
575
691
|
this.turn = turn;
|
|
@@ -653,13 +769,38 @@ var BrowserAutomation = class {
|
|
|
653
769
|
async waitUntilIdle() {
|
|
654
770
|
await this.operationTail;
|
|
655
771
|
}
|
|
656
|
-
|
|
772
|
+
/** Trusted local adapters share the built-in automation queue and exact page.
|
|
773
|
+
* The callback must disconnect/await children in finally, including on abort.
|
|
774
|
+
* Do not invoke another automation operation or handoff from the callback. */
|
|
775
|
+
withCdpOperation(operation, options = {}) {
|
|
776
|
+
return this.run((signal) => this.session.withCdpTarget(operation, signal), options);
|
|
777
|
+
}
|
|
778
|
+
/** Insert the claim at a precise queue position; later automation parks. */
|
|
779
|
+
async yieldToHuman(grant) {
|
|
780
|
+
await this.run(() => {
|
|
781
|
+
grant();
|
|
782
|
+
return Promise.resolve();
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
/** Abort first, then await callback cleanup before the owner closes Chrome. */
|
|
786
|
+
async shutdown() {
|
|
787
|
+
this.stopping.abort(/* @__PURE__ */ new Error("Browser automation stopped"));
|
|
788
|
+
this.turn.releaseHuman();
|
|
789
|
+
await this.operationTail;
|
|
790
|
+
}
|
|
791
|
+
run(operation, options = {}) {
|
|
792
|
+
const signal = options.signal ? AbortSignal.any([this.stopping.signal, options.signal]) : this.stopping.signal;
|
|
657
793
|
const result = this.operationTail.catch(() => void 0).then(async () => {
|
|
658
|
-
|
|
794
|
+
signal.throwIfAborted();
|
|
795
|
+
this.session.addHold();
|
|
659
796
|
try {
|
|
660
|
-
|
|
797
|
+
await this.turn.acquireAgent(signal);
|
|
798
|
+
signal.throwIfAborted();
|
|
799
|
+
const result = await operation(signal);
|
|
800
|
+
signal.throwIfAborted();
|
|
801
|
+
return result;
|
|
661
802
|
} finally {
|
|
662
|
-
this.session.
|
|
803
|
+
this.session.removeHold();
|
|
663
804
|
}
|
|
664
805
|
});
|
|
665
806
|
this.operationTail = result.then(() => void 0, () => void 0);
|
|
@@ -700,13 +841,13 @@ var BrowserSurface = class {
|
|
|
700
841
|
handoff = null;
|
|
701
842
|
controllerSessionId = null;
|
|
702
843
|
pendingControllerSessionId = null;
|
|
844
|
+
claimGeneration = 0;
|
|
703
845
|
streamGeneration = 0;
|
|
704
846
|
streamQueue = Promise.resolve();
|
|
705
847
|
closed = false;
|
|
706
848
|
removePageListener;
|
|
707
849
|
log;
|
|
708
850
|
constructor(options, sendFrame) {
|
|
709
|
-
this.options = options;
|
|
710
851
|
this.sendFrame = sendFrame;
|
|
711
852
|
this.log = options.logger ?? noopLogger;
|
|
712
853
|
const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
|
|
@@ -732,8 +873,11 @@ var BrowserSurface = class {
|
|
|
732
873
|
});
|
|
733
874
|
this.injector = new InputInjector(this.viewport);
|
|
734
875
|
this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
|
|
735
|
-
this.removePageListener = this.session.onActivePageChange(() => {
|
|
736
|
-
if (this.viewers.size > 0)
|
|
876
|
+
this.removePageListener = this.session.onActivePageChange(async () => {
|
|
877
|
+
if (this.viewers.size > 0) {
|
|
878
|
+
await this.restartStreaming();
|
|
879
|
+
this.broadcastState();
|
|
880
|
+
}
|
|
737
881
|
});
|
|
738
882
|
}
|
|
739
883
|
async openSession(sessionId, open) {
|
|
@@ -791,25 +935,28 @@ var BrowserSurface = class {
|
|
|
791
935
|
if (input) this.injector.key(input);
|
|
792
936
|
}
|
|
793
937
|
break;
|
|
794
|
-
case RemoteFrameType.TAKEOVER_REQUEST:
|
|
938
|
+
case RemoteFrameType.TAKEOVER_REQUEST: {
|
|
795
939
|
if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
|
|
796
940
|
this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
797
941
|
break;
|
|
798
942
|
}
|
|
799
943
|
this.pendingControllerSessionId = frame.sessionId;
|
|
800
|
-
this.
|
|
801
|
-
|
|
944
|
+
const claimGeneration = ++this.claimGeneration;
|
|
945
|
+
this.automation.yieldToHuman(() => {
|
|
946
|
+
if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
|
|
802
947
|
this.pendingControllerSessionId = null;
|
|
803
948
|
if (this.turn.grantHuman()) {
|
|
804
949
|
this.controllerSessionId = frame.sessionId;
|
|
805
950
|
this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
|
|
806
951
|
} else this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
807
952
|
}).catch((error) => {
|
|
808
|
-
if (this.pendingControllerSessionId
|
|
953
|
+
if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId) return;
|
|
954
|
+
this.pendingControllerSessionId = null;
|
|
809
955
|
this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
|
|
810
956
|
this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
811
957
|
});
|
|
812
958
|
break;
|
|
959
|
+
}
|
|
813
960
|
case RemoteFrameType.RELEASE_CONTROL:
|
|
814
961
|
if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
|
|
815
962
|
break;
|
|
@@ -819,7 +966,10 @@ var BrowserSurface = class {
|
|
|
819
966
|
closeSession(sessionId) {
|
|
820
967
|
if (!this.viewers.delete(sessionId)) return;
|
|
821
968
|
this.session.removeHold();
|
|
822
|
-
if (this.pendingControllerSessionId === sessionId)
|
|
969
|
+
if (this.pendingControllerSessionId === sessionId) {
|
|
970
|
+
this.pendingControllerSessionId = null;
|
|
971
|
+
this.claimGeneration += 1;
|
|
972
|
+
}
|
|
823
973
|
if (this.controllerSessionId === sessionId) this.releaseToAgent();
|
|
824
974
|
if (this.viewers.size === 0) {
|
|
825
975
|
this.streamGeneration += 1;
|
|
@@ -841,20 +991,28 @@ var BrowserSurface = class {
|
|
|
841
991
|
* under the user (mislabeled 409 on the real claim; agent only resuming when
|
|
842
992
|
* the tab closed).
|
|
843
993
|
*/
|
|
844
|
-
async requestHandoff(timeoutMs) {
|
|
994
|
+
async requestHandoff(timeoutMs, signal) {
|
|
845
995
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
|
|
996
|
+
if (this.closed) throw new Error("Browser surface is shut down");
|
|
997
|
+
signal?.throwIfAborted();
|
|
846
998
|
if (this.handoff) this.releaseToAgent();
|
|
847
|
-
return new Promise((resolve) => {
|
|
999
|
+
return new Promise((resolve, reject) => {
|
|
1000
|
+
const settle = (result) => {
|
|
1001
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1002
|
+
resolve(result);
|
|
1003
|
+
};
|
|
848
1004
|
const timer = setTimeout(() => {
|
|
1005
|
+
if (this.handoff !== waiter) return;
|
|
849
1006
|
this.handoff = null;
|
|
850
1007
|
this.pendingControllerSessionId = null;
|
|
1008
|
+
this.claimGeneration += 1;
|
|
851
1009
|
if (this.turn.humanInControl) {
|
|
852
1010
|
this.turn.releaseHuman();
|
|
853
1011
|
this.controllerSessionId = null;
|
|
854
1012
|
this.broadcast(RemoteFrameType.CONTROL_REVOKED);
|
|
855
1013
|
}
|
|
856
1014
|
this.currentPageInfo().then(({ url, title }) => {
|
|
857
|
-
|
|
1015
|
+
settle({
|
|
858
1016
|
released: false,
|
|
859
1017
|
timedOut: true,
|
|
860
1018
|
url,
|
|
@@ -863,10 +1021,27 @@ var BrowserSurface = class {
|
|
|
863
1021
|
});
|
|
864
1022
|
}, timeoutMs);
|
|
865
1023
|
timer.unref();
|
|
866
|
-
|
|
867
|
-
resolve,
|
|
1024
|
+
const waiter = {
|
|
1025
|
+
resolve: settle,
|
|
868
1026
|
timer
|
|
869
1027
|
};
|
|
1028
|
+
const onAbort = () => {
|
|
1029
|
+
if (this.handoff !== waiter) return;
|
|
1030
|
+
this.handoff = null;
|
|
1031
|
+
clearTimeout(timer);
|
|
1032
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1033
|
+
this.pendingControllerSessionId = null;
|
|
1034
|
+
this.claimGeneration += 1;
|
|
1035
|
+
if (this.turn.humanInControl) {
|
|
1036
|
+
this.turn.releaseHuman();
|
|
1037
|
+
this.controllerSessionId = null;
|
|
1038
|
+
this.broadcast(RemoteFrameType.CONTROL_REVOKED);
|
|
1039
|
+
}
|
|
1040
|
+
reject(/* @__PURE__ */ new Error("Browser handoff cancelled"));
|
|
1041
|
+
};
|
|
1042
|
+
this.handoff = waiter;
|
|
1043
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1044
|
+
if (signal?.aborted) onAbort();
|
|
870
1045
|
});
|
|
871
1046
|
}
|
|
872
1047
|
/**
|
|
@@ -887,6 +1062,7 @@ var BrowserSurface = class {
|
|
|
887
1062
|
this.removePageListener();
|
|
888
1063
|
this.streamGeneration += 1;
|
|
889
1064
|
this.pendingControllerSessionId = null;
|
|
1065
|
+
this.claimGeneration += 1;
|
|
890
1066
|
this.controllerSessionId = null;
|
|
891
1067
|
const waiter = this.handoff;
|
|
892
1068
|
this.handoff = null;
|
|
@@ -900,11 +1076,13 @@ var BrowserSurface = class {
|
|
|
900
1076
|
});
|
|
901
1077
|
}
|
|
902
1078
|
this.turn.releaseHuman();
|
|
1079
|
+
await this.automation.shutdown();
|
|
903
1080
|
await this.enqueueStreamCleanup();
|
|
904
1081
|
await this.session.shutdown();
|
|
905
1082
|
}
|
|
906
1083
|
releaseToAgent() {
|
|
907
1084
|
this.pendingControllerSessionId = null;
|
|
1085
|
+
this.claimGeneration += 1;
|
|
908
1086
|
this.controllerSessionId = null;
|
|
909
1087
|
this.turn.releaseHuman();
|
|
910
1088
|
const waiter = this.handoff;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"puppeteer-core": "^24.0.0",
|
|
20
|
-
"@alfe.ai/remote": "^0.
|
|
20
|
+
"@alfe.ai/remote": "^0.2.0"
|
|
21
21
|
},
|
|
22
22
|
"license": "UNLICENSED",
|
|
23
23
|
"homepage": "https://alfe.ai",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"build": "tsdown",
|
|
33
33
|
"dev": "tsdown --watch",
|
|
34
34
|
"test": "vitest run --passWithNoTests",
|
|
35
|
+
"test:integration": "tsc --noEmit -p tsconfig.integration.json && vitest run --config test/vitest.config.ts",
|
|
35
36
|
"typecheck": "tsc --noEmit",
|
|
36
37
|
"lint": "eslint ."
|
|
37
38
|
}
|