@handstage/core 0.0.7 → 1.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/README.md +48 -1
- package/dist/launch/bun.js +44 -0
- package/dist/launch/node.js +77 -0
- package/dist/launch/utils.js +88 -0
- package/dist/v3/connect/connection.js +20 -0
- package/dist/v3/connect/index.js +5 -0
- package/dist/v3/connect/local.js +106 -0
- package/dist/v3/connect/session.js +18 -0
- package/dist/v3/connect/shared.js +77 -0
- package/dist/v3/connect/transport.js +18 -0
- package/dist/v3/connect/ws.js +34 -0
- package/dist/v3/handstage.js +160 -0
- package/dist/v3/index.js +3 -3
- package/dist/v3/launch/resolveWS.js +32 -0
- package/dist/v3/logger.js +22 -31
- package/dist/v3/types/private/api.js +0 -1
- package/dist/v3/types/private/internal.js +0 -1
- package/dist/v3/types/private/snapshot.js +0 -1
- package/dist/v3/types/public/context.js +0 -1
- package/dist/v3/types/public/index.js +2 -0
- package/dist/v3/types/public/launchedChrome.js +0 -0
- package/dist/v3/types/public/locator.js +0 -1
- package/dist/v3/types/public/screenshotTypes.js +0 -1
- package/dist/v3/types/public/sdkErrors.js +22 -149
- package/dist/v3/understudy/a11y/snapshot/a11yTree.js +26 -12
- package/dist/v3/understudy/a11y/snapshot/activeElement.js +3 -1
- package/dist/v3/understudy/a11y/snapshot/capture.js +14 -4
- package/dist/v3/understudy/a11y/snapshot/coordinateResolver.js +6 -2
- package/dist/v3/understudy/a11y/snapshot/domTree.js +25 -2
- package/dist/v3/understudy/a11y/snapshot/focusSelectors.js +13 -5
- package/dist/v3/understudy/a11y/snapshot/treeFormatUtils.js +29 -10
- package/dist/v3/understudy/a11y/snapshot/xpathUtils.js +3 -2
- package/dist/v3/understudy/cdp.js +151 -135
- package/dist/v3/understudy/context.js +186 -442
- package/dist/v3/understudy/cookies.js +2 -2
- package/dist/v3/understudy/deepLocator.js +12 -7
- package/dist/v3/understudy/executionContextRegistry.js +1 -1
- package/dist/v3/understudy/frame.js +28 -6
- package/dist/v3/understudy/frameLocator.js +3 -1
- package/dist/v3/understudy/frameRegistry.js +19 -15
- package/dist/v3/understudy/initScripts.js +1 -1
- package/dist/v3/understudy/locator.js +37 -61
- package/dist/v3/understudy/navigationResponseTracker.js +5 -5
- package/dist/v3/understudy/networkManager.js +1 -1
- package/dist/v3/understudy/page.js +70 -41
- package/dist/v3/understudy/piercer.js +8 -4
- package/dist/v3/understudy/response.js +9 -4
- package/dist/v3/understudy/screenshotUtils.js +7 -2
- package/dist/v3/understudy/selectorResolver.js +2 -3
- package/dist/v3/understudy/targetRouter.js +189 -0
- package/package.json +36 -4
- package/src/launch/bun.ts +65 -0
- package/src/launch/node.ts +97 -0
- package/src/launch/utils.ts +127 -0
- package/src/v3/connect/connection.ts +32 -0
- package/src/v3/connect/index.ts +5 -0
- package/src/v3/connect/local.ts +119 -0
- package/src/v3/connect/session.ts +32 -0
- package/src/v3/connect/shared.ts +104 -0
- package/src/v3/connect/transport.ts +29 -0
- package/src/v3/connect/ws.ts +45 -0
- package/src/v3/handstage.ts +209 -0
- package/src/v3/index.ts +3 -3
- package/src/v3/launch/resolveWS.ts +39 -0
- package/src/v3/logger.ts +35 -38
- package/src/v3/types/private/internal.ts +0 -25
- package/src/v3/types/public/index.ts +8 -5
- package/src/v3/types/public/launchedChrome.ts +27 -0
- package/src/v3/types/public/locator.ts +2 -8
- package/src/v3/types/public/options.ts +0 -11
- package/src/v3/types/public/sdkErrors.ts +27 -13
- package/src/v3/understudy/a11y/snapshot/a11yTree.ts +25 -20
- package/src/v3/understudy/a11y/snapshot/activeElement.ts +6 -15
- package/src/v3/understudy/a11y/snapshot/capture.ts +20 -11
- package/src/v3/understudy/a11y/snapshot/coordinateResolver.ts +9 -16
- package/src/v3/understudy/a11y/snapshot/domTree.ts +48 -29
- package/src/v3/understudy/a11y/snapshot/focusSelectors.ts +28 -35
- package/src/v3/understudy/a11y/snapshot/treeFormatUtils.ts +34 -12
- package/src/v3/understudy/a11y/snapshot/xpathUtils.ts +9 -14
- package/src/v3/understudy/cdp.ts +357 -278
- package/src/v3/understudy/context.ts +304 -594
- package/src/v3/understudy/cookies.ts +4 -3
- package/src/v3/understudy/deepLocator.ts +16 -22
- package/src/v3/understudy/executionContextRegistry.ts +1 -1
- package/src/v3/understudy/frame.ts +56 -56
- package/src/v3/understudy/frameLocator.ts +4 -8
- package/src/v3/understudy/frameRegistry.ts +20 -17
- package/src/v3/understudy/locator.ts +166 -252
- package/src/v3/understudy/navigationResponseTracker.ts +16 -15
- package/src/v3/understudy/networkManager.ts +1 -1
- package/src/v3/understudy/page.ts +149 -141
- package/src/v3/understudy/piercer.ts +22 -24
- package/src/v3/understudy/response.ts +17 -12
- package/src/v3/understudy/screenshotUtils.ts +15 -12
- package/src/v3/understudy/selectorResolver.ts +41 -55
- package/src/v3/understudy/targetRouter.ts +236 -0
- package/dist/v3/cli.js +0 -9
- package/dist/v3/launch/local.js +0 -94
- package/dist/v3/shutdown/cleanupLocal.js +0 -25
- package/dist/v3/shutdown/supervisor.js +0 -153
- package/dist/v3/shutdown/supervisorClient.js +0 -86
- package/dist/v3/types/private/locator.js +0 -1
- package/dist/v3/types/private/shutdown.js +0 -4
- package/dist/v3/types/private/shutdownErrors.js +0 -21
- package/dist/v3/understudy/fileUploadUtils.js +0 -80
- package/dist/v3/v3.js +0 -433
- package/src/v3/launch/local.ts +0 -131
- package/src/v3/shutdown/cleanupLocal.ts +0 -31
- package/src/v3/shutdown/supervisor.ts +0 -173
- package/src/v3/shutdown/supervisorClient.ts +0 -122
- package/src/v3/types/private/locator.ts +0 -10
- package/src/v3/types/private/shutdown.ts +0 -16
- package/src/v3/types/private/shutdownErrors.ts +0 -24
- package/src/v3/understudy/fileUploadUtils.ts +0 -102
- package/src/v3/v3.ts +0 -494
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
1
|
# @handstage/core
|
|
2
2
|
|
|
3
|
-
Core browser automation engine for Handstage. Manages
|
|
3
|
+
Core browser automation engine for Handstage. Manages CDP connections,
|
|
4
|
+
target routing, page/frame lifecycle, and script injection for reliable
|
|
5
|
+
browser automation.
|
|
6
|
+
|
|
7
|
+
## Connection ownership
|
|
8
|
+
|
|
9
|
+
Connection subpath factories own the CDP connection they construct:
|
|
10
|
+
|
|
11
|
+
- `connectLocal(chrome)` from `@handstage/core/connect/local` — attaches to
|
|
12
|
+
a launched Chrome pipe and owns the connection.
|
|
13
|
+
- `connectWS(ws)` from `@handstage/core/connect/ws` — wraps a browser
|
|
14
|
+
WebSocket and owns the connection. `close()` closes the WebSocket.
|
|
15
|
+
- `connectTransport(transport)` from `@handstage/core/connect/transport` —
|
|
16
|
+
wraps and owns a raw `CDPTransport`.
|
|
17
|
+
- `connectSession(session)` from `@handstage/core/connect/session` — wraps
|
|
18
|
+
and owns an `ExternalCDPSession`.
|
|
19
|
+
- `connectConnection(existingConnection)` from
|
|
20
|
+
`@handstage/core/connect/connection` — explicit sharing entrypoint. Handstage does
|
|
21
|
+
NOT close the connection on `close()`; the caller does.
|
|
22
|
+
|
|
23
|
+
Wrapping the same `transport` or `session` again throws
|
|
24
|
+
`HandstageTransportAlreadyOwnedError`.
|
|
25
|
+
|
|
26
|
+
`Context.close()` only ever calls `Target.disposeBrowserContext` (for
|
|
27
|
+
dedicated contexts). It never tears down the underlying CDP connection —
|
|
28
|
+
that responsibility lives with `Handstage` or, for shared connections, the caller.
|
|
29
|
+
|
|
30
|
+
## Default-context attach
|
|
31
|
+
|
|
32
|
+
Handstage creates instances containing the `defaultBrowserContext()` by default (which aligns with Puppeteer's `puppeteer.connect` and `puppeteer.launch`).
|
|
33
|
+
Two Handstage clients on the same CDP websocket therefore share the default browser context natively without breaking. If you want isolation, call `handstage.createBrowserContext()`, which returns an isolated browser context. Targets owned by other browser contexts are resumed/detached at the target router rather than left paused.
|
|
34
|
+
|
|
35
|
+
To intentionally attach to the shared default context (and accept that
|
|
36
|
+
other actors may race with you on a shared tab), simply use the default browser context (i.e. `handstage.newPage()`).
|
|
37
|
+
|
|
38
|
+
## Active page is gone
|
|
39
|
+
|
|
40
|
+
Contexts no longer auto-create an initial page and there is no
|
|
41
|
+
`context.activePage()` / `setActivePage()` / `awaitActivePage()`. Track
|
|
42
|
+
`Page` references yourself (from `newPage()` / `pages()`) and call
|
|
43
|
+
`page.bringToFront()` if you want to foreground a tab.
|
|
44
|
+
|
|
45
|
+
## Per-instance logging
|
|
46
|
+
|
|
47
|
+
Pass `logger:` to any connection factory and that logger receives every log
|
|
48
|
+
from that Handstage's contexts / pages / network managers / target-router delegate.
|
|
49
|
+
Two Handstage instances on a shared connection each receive router-level debug lines
|
|
50
|
+
via broadcast.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
import Bun from "bun";
|
|
3
|
+
import { performBrowserProcessCleanup, prepareChromeLaunchOptions, } from "./utils";
|
|
4
|
+
export async function launchChromeBun(opts) {
|
|
5
|
+
const lbo = opts ?? {};
|
|
6
|
+
const { chromePath, finalFlags, userDataDir, createdTemp } = prepareChromeLaunchOptions(lbo);
|
|
7
|
+
const p = Bun.spawn([chromePath, ...finalFlags], {
|
|
8
|
+
stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"],
|
|
9
|
+
});
|
|
10
|
+
const fd3 = p.stdio[3]; // Chrome's read pipe
|
|
11
|
+
const fd4 = p.stdio[4]; // Chrome's write pipe
|
|
12
|
+
if (typeof fd3 !== "number" || typeof fd4 !== "number") {
|
|
13
|
+
throw new Error("Failed to map Chrome pipes to Bun stdio streams");
|
|
14
|
+
}
|
|
15
|
+
const fd3Writer = Bun.file(fd3).writer();
|
|
16
|
+
const fd4Reader = Bun.file(fd4).stream();
|
|
17
|
+
const stdin = new WritableStream({
|
|
18
|
+
write(chunk) {
|
|
19
|
+
fd3Writer.write(chunk);
|
|
20
|
+
fd3Writer.flush();
|
|
21
|
+
},
|
|
22
|
+
close() {
|
|
23
|
+
fd3Writer.end();
|
|
24
|
+
},
|
|
25
|
+
abort() {
|
|
26
|
+
fd3Writer.end();
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const close = async () => {
|
|
30
|
+
try {
|
|
31
|
+
fd3Writer.end();
|
|
32
|
+
await performBrowserProcessCleanup((signal) => p.kill(signal), p.exited, userDataDir, createdTemp, lbo);
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
stdout: fd4Reader,
|
|
38
|
+
stdin,
|
|
39
|
+
close,
|
|
40
|
+
pid: p.pid,
|
|
41
|
+
userDataDir,
|
|
42
|
+
createdTempProfile: createdTemp,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { Readable, Writable } from "node:stream";
|
|
4
|
+
import { performBrowserProcessCleanup, prepareChromeLaunchOptions, } from "./utils";
|
|
5
|
+
export async function launchChromeNode(opts) {
|
|
6
|
+
const lbo = opts ?? {};
|
|
7
|
+
const { chromePath, finalFlags, userDataDir, createdTemp } = prepareChromeLaunchOptions(lbo);
|
|
8
|
+
const p = spawn(chromePath, finalFlags, {
|
|
9
|
+
stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
const fd3 = p.stdio[3]; // Chrome's read pipe (our WritableStream)
|
|
12
|
+
const fd4 = p.stdio[4]; // Chrome's write pipe (our ReadableStream)
|
|
13
|
+
if (!(fd3 instanceof Writable) || !(fd4 instanceof Readable)) {
|
|
14
|
+
throw new Error("Failed to map Chrome pipes to stdio");
|
|
15
|
+
}
|
|
16
|
+
const stdout = new ReadableStream({
|
|
17
|
+
start(controller) {
|
|
18
|
+
fd4.on("data", (chunk) => {
|
|
19
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
20
|
+
});
|
|
21
|
+
fd4.on("end", () => {
|
|
22
|
+
controller.close();
|
|
23
|
+
});
|
|
24
|
+
fd4.on("error", (err) => {
|
|
25
|
+
controller.error(err);
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
cancel() {
|
|
29
|
+
fd4.destroy();
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const stdin = new WritableStream({
|
|
33
|
+
write(chunk, controller) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
fd3.write(chunk, (err) => {
|
|
36
|
+
if (err) {
|
|
37
|
+
controller.error(err);
|
|
38
|
+
reject(err);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
resolve();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
close() {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
fd3.end(resolve);
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
abort(err) {
|
|
52
|
+
fd3.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const close = async () => {
|
|
56
|
+
try {
|
|
57
|
+
fd3.destroy();
|
|
58
|
+
fd4.destroy();
|
|
59
|
+
const exited = new Promise((resolve) => {
|
|
60
|
+
if (p.exitCode !== null || p.signalCode !== null)
|
|
61
|
+
resolve();
|
|
62
|
+
else
|
|
63
|
+
p.once("exit", resolve);
|
|
64
|
+
});
|
|
65
|
+
await performBrowserProcessCleanup((signal) => p.kill(signal), exited, userDataDir, createdTemp, lbo);
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
stdout,
|
|
71
|
+
stdin,
|
|
72
|
+
close,
|
|
73
|
+
pid: p.pid,
|
|
74
|
+
userDataDir,
|
|
75
|
+
createdTempProfile: createdTemp,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { getChromePath } from "chrome-launcher";
|
|
6
|
+
// `DEFAULT_FLAGS` is intentionally not re-exported from chrome-launcher's
|
|
7
|
+
// package entry, so import it from the flags module directly.
|
|
8
|
+
import { DEFAULT_FLAGS } from "chrome-launcher/dist/flags.js";
|
|
9
|
+
export const CHROME_EXIT_TIMEOUT_MS = 5000;
|
|
10
|
+
export async function waitForProcessExit(exited, timeoutMs) {
|
|
11
|
+
return Promise.race([
|
|
12
|
+
exited.then(() => true, () => true),
|
|
13
|
+
new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
export async function performBrowserProcessCleanup(kill, exited, userDataDir, createdTemp, opts) {
|
|
17
|
+
kill();
|
|
18
|
+
if (!(await waitForProcessExit(exited, CHROME_EXIT_TIMEOUT_MS))) {
|
|
19
|
+
kill("SIGKILL");
|
|
20
|
+
await waitForProcessExit(exited, CHROME_EXIT_TIMEOUT_MS);
|
|
21
|
+
}
|
|
22
|
+
cleanupUserDataDir(userDataDir, createdTemp, opts);
|
|
23
|
+
}
|
|
24
|
+
export function prepareChromeLaunchOptions(opts) {
|
|
25
|
+
const lbo = opts ?? {};
|
|
26
|
+
const chromePath = lbo.executablePath || getChromePath();
|
|
27
|
+
let userDataDir = lbo.userDataDir;
|
|
28
|
+
let createdTemp = false;
|
|
29
|
+
if (!userDataDir) {
|
|
30
|
+
const base = path.join(os.tmpdir(), "handstage-v3");
|
|
31
|
+
fs.mkdirSync(base, { recursive: true });
|
|
32
|
+
userDataDir = fs.mkdtempSync(path.join(base, "profile-"));
|
|
33
|
+
createdTemp = true;
|
|
34
|
+
}
|
|
35
|
+
let baseChromeFlags = [];
|
|
36
|
+
const ignore = lbo.ignoreDefaultArgs;
|
|
37
|
+
if (ignore === true) {
|
|
38
|
+
baseChromeFlags = [];
|
|
39
|
+
}
|
|
40
|
+
else if (Array.isArray(ignore)) {
|
|
41
|
+
baseChromeFlags = DEFAULT_FLAGS.filter((f) => !ignore.some((ex) => f.includes(ex)));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
baseChromeFlags = [...DEFAULT_FLAGS];
|
|
45
|
+
}
|
|
46
|
+
const chromeFlags = [
|
|
47
|
+
...(lbo.headless !== false ? ["--headless=new"] : []),
|
|
48
|
+
...baseChromeFlags,
|
|
49
|
+
"--remote-debugging-pipe",
|
|
50
|
+
];
|
|
51
|
+
if (lbo.devtools)
|
|
52
|
+
chromeFlags.push("--auto-open-devtools-for-tabs");
|
|
53
|
+
if (lbo.locale)
|
|
54
|
+
chromeFlags.push(`--lang=${lbo.locale}`);
|
|
55
|
+
if (lbo.viewport?.width && lbo.viewport?.height) {
|
|
56
|
+
chromeFlags.push(`--window-size=${lbo.viewport.width},${lbo.viewport.height + 87}`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof lbo.deviceScaleFactor === "number") {
|
|
59
|
+
chromeFlags.push(`--force-device-scale-factor=${Math.max(0.1, lbo.deviceScaleFactor)}`);
|
|
60
|
+
}
|
|
61
|
+
if (lbo.hasTouch)
|
|
62
|
+
chromeFlags.push("--touch-events=enabled");
|
|
63
|
+
if (lbo.ignoreHTTPSErrors)
|
|
64
|
+
chromeFlags.push("--ignore-certificate-errors");
|
|
65
|
+
if (lbo.proxy?.server)
|
|
66
|
+
chromeFlags.push(`--proxy-server=${lbo.proxy.server}`);
|
|
67
|
+
if (lbo.proxy?.bypass)
|
|
68
|
+
chromeFlags.push(`--proxy-bypass-list=${lbo.proxy.bypass}`);
|
|
69
|
+
if (userDataDir)
|
|
70
|
+
chromeFlags.push(`--user-data-dir=${userDataDir}`);
|
|
71
|
+
if (Array.isArray(lbo.args))
|
|
72
|
+
chromeFlags.push(...lbo.args);
|
|
73
|
+
const finalFlags = chromeFlags.filter((f) => typeof f === "string");
|
|
74
|
+
return {
|
|
75
|
+
chromePath,
|
|
76
|
+
finalFlags,
|
|
77
|
+
userDataDir,
|
|
78
|
+
createdTemp,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function cleanupUserDataDir(userDataDir, createdTemp, opts) {
|
|
82
|
+
if (createdTemp && !opts?.preserveUserDataDir && userDataDir) {
|
|
83
|
+
try {
|
|
84
|
+
fs.rmSync(userDataDir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { LogLevel } from "../types/public/logs";
|
|
2
|
+
import { connectOptionsToLocalBrowserLaunchOptions, createSharedHandstage, setupConnectContext, } from "./shared";
|
|
3
|
+
/**
|
|
4
|
+
* Attach a Handstage instance to a pre-existing `CDPConnectionLike` that the caller
|
|
5
|
+
* manages. Handstage will not close the shared connection on `close()`.
|
|
6
|
+
*/
|
|
7
|
+
export async function connectConnection(conn, opts) {
|
|
8
|
+
const { sharedOpts, logSink, logger } = setupConnectContext(opts);
|
|
9
|
+
logger({
|
|
10
|
+
category: "init",
|
|
11
|
+
message: "Attaching to shared CDP connection",
|
|
12
|
+
level: LogLevel.Info,
|
|
13
|
+
});
|
|
14
|
+
return await createSharedHandstage({
|
|
15
|
+
conn,
|
|
16
|
+
lbo: connectOptionsToLocalBrowserLaunchOptions(opts),
|
|
17
|
+
sharedOpts,
|
|
18
|
+
logSink,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { LogLevel } from "../types/public/logs";
|
|
2
|
+
import { CDPConnection } from "../understudy/cdp";
|
|
3
|
+
import { createOwnedHandstage, setupConnectContext } from "./shared";
|
|
4
|
+
const textEncoder = new TextEncoder();
|
|
5
|
+
function createUtf8DecoderStream() {
|
|
6
|
+
const decoder = new TextDecoder();
|
|
7
|
+
return new TransformStream({
|
|
8
|
+
transform(chunk, controller) {
|
|
9
|
+
controller.enqueue(decoder.decode(chunk, { stream: true }));
|
|
10
|
+
},
|
|
11
|
+
flush(controller) {
|
|
12
|
+
const trailing = decoder.decode();
|
|
13
|
+
if (trailing)
|
|
14
|
+
controller.enqueue(trailing);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function* readNullDelimitedMessages(stream) {
|
|
19
|
+
const decodedStream = stream.pipeThrough(createUtf8DecoderStream());
|
|
20
|
+
const reader = decodedStream.getReader();
|
|
21
|
+
let pending = "";
|
|
22
|
+
try {
|
|
23
|
+
while (true) {
|
|
24
|
+
const { value, done } = await reader.read();
|
|
25
|
+
if (done)
|
|
26
|
+
break;
|
|
27
|
+
pending += value;
|
|
28
|
+
let frameStart = 0;
|
|
29
|
+
while (true) {
|
|
30
|
+
const frameEnd = pending.indexOf("\0", frameStart);
|
|
31
|
+
if (frameEnd === -1)
|
|
32
|
+
break;
|
|
33
|
+
yield pending.slice(frameStart, frameEnd);
|
|
34
|
+
frameStart = frameEnd + 1;
|
|
35
|
+
}
|
|
36
|
+
if (frameStart > 0) {
|
|
37
|
+
pending = pending.slice(frameStart);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
reader.releaseLock();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function encodeNullDelimitedMessage(message) {
|
|
46
|
+
const encoded = textEncoder.encode(message);
|
|
47
|
+
const framed = new Uint8Array(encoded.byteLength + 1);
|
|
48
|
+
framed.set(encoded);
|
|
49
|
+
return framed;
|
|
50
|
+
}
|
|
51
|
+
export async function connectLocal(chrome, opts) {
|
|
52
|
+
const { sharedOpts, logSink, logger } = setupConnectContext(opts);
|
|
53
|
+
logger({
|
|
54
|
+
category: "init",
|
|
55
|
+
message: "Connecting via LaunchedChrome (pipe)",
|
|
56
|
+
level: LogLevel.Info,
|
|
57
|
+
});
|
|
58
|
+
const writer = chrome.stdin.getWriter();
|
|
59
|
+
let isClosed = false;
|
|
60
|
+
const transport = {
|
|
61
|
+
send: (message) => {
|
|
62
|
+
if (isClosed)
|
|
63
|
+
return;
|
|
64
|
+
writer.write(encodeNullDelimitedMessage(message)).catch(() => { });
|
|
65
|
+
},
|
|
66
|
+
close: async () => {
|
|
67
|
+
if (isClosed)
|
|
68
|
+
return;
|
|
69
|
+
isClosed = true;
|
|
70
|
+
await writer.close().catch(() => { });
|
|
71
|
+
await chrome.close().catch(() => { });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
for await (const message of readNullDelimitedMessages(chrome.stdout)) {
|
|
77
|
+
if (isClosed)
|
|
78
|
+
break;
|
|
79
|
+
if (transport.onmessage)
|
|
80
|
+
transport.onmessage(message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (transport.onerror) {
|
|
85
|
+
transport.onerror(err instanceof Error ? err : new Error(String(err)));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
if (transport.onclose && !isClosed) {
|
|
90
|
+
transport.onclose("Pipe closed");
|
|
91
|
+
}
|
|
92
|
+
isClosed = true;
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
const conn = new CDPConnection(transport);
|
|
96
|
+
const lbo = opts?.localBrowserLaunchOptions ?? {};
|
|
97
|
+
return await createOwnedHandstage({
|
|
98
|
+
conn,
|
|
99
|
+
lbo,
|
|
100
|
+
sharedOpts,
|
|
101
|
+
logSink,
|
|
102
|
+
onContextError: async () => {
|
|
103
|
+
await chrome.close().catch(() => { });
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { LogLevel } from "../types/public/logs";
|
|
2
|
+
import { ExternalConnectionAdapter, } from "../understudy/cdp";
|
|
3
|
+
import { connectOptionsToLocalBrowserLaunchOptions, createOwnedHandstage, setupConnectContext, } from "./shared";
|
|
4
|
+
export async function connectSession(session, opts) {
|
|
5
|
+
const { sharedOpts, logSink, logger } = setupConnectContext(opts);
|
|
6
|
+
logger({
|
|
7
|
+
category: "init",
|
|
8
|
+
message: "Connecting via custom connection",
|
|
9
|
+
level: LogLevel.Info,
|
|
10
|
+
});
|
|
11
|
+
const adapter = new ExternalConnectionAdapter(session);
|
|
12
|
+
return await createOwnedHandstage({
|
|
13
|
+
conn: adapter,
|
|
14
|
+
lbo: connectOptionsToLocalBrowserLaunchOptions(opts),
|
|
15
|
+
sharedOpts,
|
|
16
|
+
logSink,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHandstageForConnection } from "../handstage";
|
|
2
|
+
import { createFilteredLogger } from "../logger";
|
|
3
|
+
import { Context } from "../understudy/context";
|
|
4
|
+
export function setupConnectContext(opts) {
|
|
5
|
+
const sharedOpts = opts ?? {};
|
|
6
|
+
const logSink = createFilteredLogger(sharedOpts.logger, sharedOpts.verbose);
|
|
7
|
+
const logger = (line) => logSink(line);
|
|
8
|
+
return { sharedOpts, logSink, logger };
|
|
9
|
+
}
|
|
10
|
+
export function connectOptionsToLocalBrowserLaunchOptions(opts) {
|
|
11
|
+
return opts
|
|
12
|
+
? {
|
|
13
|
+
viewport: opts.viewport,
|
|
14
|
+
deviceScaleFactor: opts.deviceScaleFactor,
|
|
15
|
+
downloadsPath: opts.downloadsPath,
|
|
16
|
+
acceptDownloads: opts.acceptDownloads,
|
|
17
|
+
}
|
|
18
|
+
: {};
|
|
19
|
+
}
|
|
20
|
+
export function onceAsync(fn) {
|
|
21
|
+
let called = false;
|
|
22
|
+
return async () => {
|
|
23
|
+
if (called)
|
|
24
|
+
return;
|
|
25
|
+
called = true;
|
|
26
|
+
await fn();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function createOwnedHandstage(params) {
|
|
30
|
+
let ctx;
|
|
31
|
+
try {
|
|
32
|
+
ctx = await Context.createFromConnection(params.conn, {
|
|
33
|
+
localBrowserLaunchOptions: params.lbo,
|
|
34
|
+
logger: params.logSink,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
await params.conn.close().catch(() => { });
|
|
39
|
+
await params.onContextError?.();
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
const cleanup = onceAsync(async () => {
|
|
43
|
+
await params.conn.close().catch(() => { });
|
|
44
|
+
});
|
|
45
|
+
const handstage = createHandstageForConnection({
|
|
46
|
+
connection: params.conn,
|
|
47
|
+
cleanup,
|
|
48
|
+
defaultContext: ctx,
|
|
49
|
+
opts: params.sharedOpts,
|
|
50
|
+
logSink: params.logSink,
|
|
51
|
+
});
|
|
52
|
+
await applyPostConnectLocalOptions(handstage, params.lbo);
|
|
53
|
+
return handstage;
|
|
54
|
+
}
|
|
55
|
+
export async function createSharedHandstage(params) {
|
|
56
|
+
const ctx = await Context.createFromConnection(params.conn, {
|
|
57
|
+
localBrowserLaunchOptions: params.lbo,
|
|
58
|
+
logger: params.logSink,
|
|
59
|
+
});
|
|
60
|
+
const handstage = createHandstageForConnection({
|
|
61
|
+
connection: params.conn,
|
|
62
|
+
defaultContext: ctx,
|
|
63
|
+
opts: params.sharedOpts,
|
|
64
|
+
logSink: params.logSink,
|
|
65
|
+
});
|
|
66
|
+
await applyPostConnectLocalOptions(handstage, params.lbo);
|
|
67
|
+
return handstage;
|
|
68
|
+
}
|
|
69
|
+
async function applyPostConnectLocalOptions(handstage, lbo) {
|
|
70
|
+
await handstage
|
|
71
|
+
.defaultBrowserContext()
|
|
72
|
+
.setDownloadBehavior({
|
|
73
|
+
downloadPath: lbo.downloadsPath,
|
|
74
|
+
acceptDownloads: lbo.acceptDownloads,
|
|
75
|
+
})
|
|
76
|
+
.catch(() => { });
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { LogLevel } from "../types/public/logs";
|
|
2
|
+
import { CDPConnection } from "../understudy/cdp";
|
|
3
|
+
import { connectOptionsToLocalBrowserLaunchOptions, createOwnedHandstage, setupConnectContext, } from "./shared";
|
|
4
|
+
export async function connectTransport(transport, opts) {
|
|
5
|
+
const { sharedOpts, logSink, logger } = setupConnectContext(opts);
|
|
6
|
+
logger({
|
|
7
|
+
category: "init",
|
|
8
|
+
message: "Connecting via custom transport",
|
|
9
|
+
level: LogLevel.Info,
|
|
10
|
+
});
|
|
11
|
+
const conn = new CDPConnection(transport);
|
|
12
|
+
return await createOwnedHandstage({
|
|
13
|
+
conn,
|
|
14
|
+
lbo: connectOptionsToLocalBrowserLaunchOptions(opts),
|
|
15
|
+
sharedOpts,
|
|
16
|
+
logSink,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { LogLevel } from "../types/public/logs";
|
|
2
|
+
import { CDPConnection } from "../understudy/cdp";
|
|
3
|
+
import { connectOptionsToLocalBrowserLaunchOptions, createOwnedHandstage, setupConnectContext, } from "./shared";
|
|
4
|
+
export async function connectWS(ws, opts) {
|
|
5
|
+
const { sharedOpts, logSink, logger } = setupConnectContext(opts);
|
|
6
|
+
logger({
|
|
7
|
+
category: "init",
|
|
8
|
+
message: "Connecting via WebSocket",
|
|
9
|
+
level: LogLevel.Info,
|
|
10
|
+
});
|
|
11
|
+
const transport = {
|
|
12
|
+
send: (message) => ws.send(message),
|
|
13
|
+
close: () => ws.close(),
|
|
14
|
+
};
|
|
15
|
+
ws.addEventListener("message", (event) => {
|
|
16
|
+
if (transport.onmessage)
|
|
17
|
+
transport.onmessage(event.data.toString());
|
|
18
|
+
});
|
|
19
|
+
ws.addEventListener("close", (event) => {
|
|
20
|
+
if (transport.onclose)
|
|
21
|
+
transport.onclose(`code=${event.code} reason=${event.reason}`);
|
|
22
|
+
});
|
|
23
|
+
ws.addEventListener("error", () => {
|
|
24
|
+
if (transport.onerror)
|
|
25
|
+
transport.onerror(new Error("WebSocket error"));
|
|
26
|
+
});
|
|
27
|
+
const conn = new CDPConnection(transport);
|
|
28
|
+
return await createOwnedHandstage({
|
|
29
|
+
conn,
|
|
30
|
+
lbo: connectOptionsToLocalBrowserLaunchOptions(opts),
|
|
31
|
+
sharedOpts,
|
|
32
|
+
logSink,
|
|
33
|
+
});
|
|
34
|
+
}
|