@xevy/heny-connect 0.1.0 → 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 +53 -15
- package/bin/heny-connect.mjs +106 -39
- package/lib/browser-controller.mjs +242 -0
- package/lib/cdp.mjs +95 -0
- package/lib/network-policy.mjs +102 -0
- package/lib/state.mjs +54 -0
- package/lib/validating-proxy.mjs +70 -0
- package/lib/worker.mjs +241 -0
- package/package.json +5 -3
- package/windows/heny-connect-tray.ps1 +264 -0
- package/windows/heny-error.ico +0 -0
- package/windows/heny-paused.ico +0 -0
- package/windows/heny.ico +0 -0
package/README.md
CHANGED
|
@@ -1,32 +1,70 @@
|
|
|
1
1
|
# @xevy/heny-connect
|
|
2
2
|
|
|
3
|
-
Heny Connect
|
|
3
|
+
Heny Connect runs a dedicated browser worker on a paired Windows PC. It stays in the system tray, starts at sign-in, and executes three explicit commands from Heny Desktop:
|
|
4
|
+
|
|
5
|
+
- Navigate to an approved public HTTP(S) URL
|
|
6
|
+
- Read up to 50,000 characters of visible page text
|
|
7
|
+
- Capture a viewport PNG up to 2 MiB
|
|
4
8
|
|
|
5
9
|
## Requirements
|
|
6
10
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
11
|
+
- Windows 10 or 11
|
|
12
|
+
- Microsoft Edge or Google Chrome
|
|
13
|
+
- Node.js 22 or newer
|
|
14
|
+
- A six-digit code from Heny **Desktop**
|
|
15
|
+
|
|
16
|
+
## Install and pair
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
Open PowerShell once:
|
|
11
19
|
|
|
12
|
-
```
|
|
13
|
-
|
|
20
|
+
```powershell
|
|
21
|
+
npm.cmd install -g @xevy/heny-connect@latest
|
|
22
|
+
heny-connect.cmd pair --code 123456 --server https://heny.vyte.dev
|
|
23
|
+
heny-connect.cmd install
|
|
14
24
|
```
|
|
15
25
|
|
|
16
|
-
The
|
|
26
|
+
The code works once and expires after 15 minutes. `install` starts the tray immediately and registers **HenyConnect** for the current user's Windows sign-in.
|
|
27
|
+
|
|
28
|
+
Heny Connect stores its registration and dedicated browser profile under `%LOCALAPPDATA%\Heny Connect`. Windows ACLs grant access to the current user and SYSTEM.
|
|
29
|
+
|
|
30
|
+
## System tray
|
|
31
|
+
|
|
32
|
+
The tray reports:
|
|
33
|
+
|
|
34
|
+
- Browser starting
|
|
35
|
+
- Available
|
|
36
|
+
- Working, including the current action
|
|
37
|
+
- Paused
|
|
38
|
+
- Error
|
|
39
|
+
- Offline
|
|
40
|
+
|
|
41
|
+
The menu provides **Open Heny**, **Pause/Resume**, **Reconnect now**, **Start at sign-in**, and **Quit**. Pause aborts current work, closes the dedicated browser, and reaches the local Paused state within five seconds during a network interruption.
|
|
42
|
+
|
|
43
|
+
## Dedicated browser
|
|
44
|
+
|
|
45
|
+
The companion owns a separate persistent Chromium profile. Logins created in that window remain available after worker and browser restarts.
|
|
46
|
+
|
|
47
|
+
Browser traffic uses a loopback validating proxy. Each HTTP request and HTTPS tunnel resolves the destination, rejects local/private/reserved addresses, and connects directly to the validated address. Chromium retains the destination Host and TLS certificate checks. Downloads, popups, external protocols, and browser permission grants are blocked.
|
|
48
|
+
|
|
49
|
+
The command protocol excludes click, type, submit, upload, shell execution, command-supplied JavaScript, and personal-profile attachment.
|
|
17
50
|
|
|
18
|
-
##
|
|
51
|
+
## Commands
|
|
19
52
|
|
|
20
|
-
```
|
|
21
|
-
|
|
53
|
+
```powershell
|
|
54
|
+
heny-connect.cmd status
|
|
55
|
+
heny-connect.cmd pause
|
|
56
|
+
heny-connect.cmd resume
|
|
57
|
+
heny-connect.cmd tray
|
|
58
|
+
heny-connect.cmd install
|
|
59
|
+
heny-connect.cmd uninstall
|
|
22
60
|
```
|
|
23
61
|
|
|
24
|
-
|
|
62
|
+
`status` shows server registration plus content-free local worker status. Device tokens, browsing content, URLs, page titles, screenshots, and browser debugger details stay out of console output.
|
|
25
63
|
|
|
26
|
-
##
|
|
64
|
+
## Remove start-at-sign-in
|
|
27
65
|
|
|
28
|
-
```
|
|
29
|
-
|
|
66
|
+
```powershell
|
|
67
|
+
heny-connect.cmd uninstall
|
|
30
68
|
```
|
|
31
69
|
|
|
32
|
-
|
|
70
|
+
Use **Quit** from the tray to end the current worker session.
|
package/bin/heny-connect.mjs
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Heny Connect — pairs
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* npx @xevy/heny-connect pair --code 123456 --server https://heny.example
|
|
7
|
-
* npx @xevy/heny-connect run
|
|
8
|
-
* npx @xevy/heny-connect status
|
|
9
|
-
*
|
|
10
|
-
* Pairing state is stored in ~/.heny-connect.json (mode 600).
|
|
3
|
+
* Heny Connect — pairs a Windows computer, owns a dedicated browser profile,
|
|
4
|
+
* and runs bounded browser commands without an open terminal.
|
|
11
5
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { hostname, platform, release } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { CAPABILITIES, DeviceApi, runWorker } from "../lib/worker.mjs";
|
|
12
|
+
import { CONNECT_HOME, PAUSE_FILE, STATUS_FILE, loadState, protectConnectHome, saveState, writeStatus } from "../lib/state.mjs";
|
|
15
13
|
|
|
16
|
-
const
|
|
14
|
+
const CLI_FILE = fileURLToPath(import.meta.url);
|
|
15
|
+
const TRAY_SCRIPT = join(dirname(CLI_FILE), "..", "windows", "heny-connect-tray.ps1");
|
|
17
16
|
const args = process.argv.slice(2);
|
|
18
17
|
const command = args[0] || "help";
|
|
19
18
|
const opt = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : fallback; };
|
|
@@ -25,50 +24,118 @@ function systemLabel() {
|
|
|
25
24
|
return `${os} ${release()}`;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
async function loadState() { try { return JSON.parse(await readFile(STATE_FILE, "utf8")); } catch { return null; } }
|
|
29
|
-
async function saveState(state) { await writeFile(STATE_FILE, JSON.stringify(state, null, 2)); await chmod(STATE_FILE, 0o600).catch(() => undefined); }
|
|
30
|
-
|
|
31
27
|
async function call(server, path, body, token) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
const response = await fetch(new URL(path, server), {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
signal: AbortSignal.timeout(20_000),
|
|
33
|
+
});
|
|
34
|
+
let parsed = null;
|
|
35
|
+
try { parsed = await response.json(); } catch {}
|
|
36
|
+
if (!response.ok) throw Object.assign(new Error(parsed?.error?.message || `Server answered HTTP ${response.status}`), { status: response.status, code: parsed?.error?.code || "server_error" });
|
|
36
37
|
return parsed;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
async function pair() {
|
|
40
|
-
const code = opt("code");
|
|
41
|
+
const code = opt("code");
|
|
42
|
+
const server = opt("server", process.env.HENY_SERVER);
|
|
41
43
|
if (!code || !server) { console.error("Usage: heny-connect pair --code 123456 --server https://heny.example"); process.exitCode = 2; return; }
|
|
42
|
-
|
|
44
|
+
await protectConnectHome();
|
|
45
|
+
const result = await call(server, "/api/devices/pair", { code, system: systemLabel(), hostname: hostname(), capabilities: CAPABILITIES });
|
|
43
46
|
await saveState({ server, token: result.token, deviceId: result.deviceId, workspace: result.workspace, pairedAt: new Date().toISOString() });
|
|
47
|
+
await unlink(PAUSE_FILE).catch(() => undefined);
|
|
44
48
|
console.log(`Paired with ${result.workspace?.name ?? "workspace"} as device ${result.deviceId}.`);
|
|
45
|
-
if (
|
|
49
|
+
if (args.includes("--run")) await run();
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
async function
|
|
49
|
-
|
|
52
|
+
async function run() {
|
|
53
|
+
const state = await loadState();
|
|
54
|
+
if (!state) { console.error("This computer needs pairing. Open Heny Desktop to get a code."); process.exitCode = 2; return; }
|
|
55
|
+
try {
|
|
56
|
+
await runWorker(state);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const detailCode = error?.status === 401 ? "re_pair_required" : (/^[a-z0-9_]{1,40}$/.test(error?.code || "") ? error.code : "worker_error");
|
|
59
|
+
await writeStatus({ state: "error", detailCode, currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
60
|
+
console.error(error?.code === "device_revoked" ? "Registration was revoked. Pair this computer again." : error.message);
|
|
61
|
+
process.exitCode = error?.status === 401 ? 3 : 1;
|
|
62
|
+
}
|
|
50
63
|
}
|
|
51
64
|
|
|
52
|
-
async function
|
|
65
|
+
async function heartbeatOnce() {
|
|
53
66
|
const state = await loadState();
|
|
54
|
-
if (!state) { console.error("
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (beats && count >= beats) process.exit(0);
|
|
61
|
-
};
|
|
62
|
-
await tick();
|
|
63
|
-
if (!beats || count < beats) setInterval(tick, every);
|
|
67
|
+
if (!state) { console.error("This computer needs pairing. Open Heny Desktop to get a code."); process.exitCode = 2; return; }
|
|
68
|
+
const stateName = opt("state", "available");
|
|
69
|
+
if (!["available", "paused", "offline", "browser_starting", "error"].includes(stateName)) { console.error("Choose a valid worker state."); process.exitCode = 2; return; }
|
|
70
|
+
const browserReady = stateName === "available";
|
|
71
|
+
await new DeviceApi(state).heartbeat(stateName, browserReady);
|
|
72
|
+
console.log(`Heartbeat reported ${stateName}.`);
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
async function status() {
|
|
67
76
|
const state = await loadState();
|
|
68
77
|
if (!state) { console.log("Not paired."); return; }
|
|
69
|
-
const
|
|
70
|
-
|
|
78
|
+
const response = await fetch(new URL("/api/devices/me", state.server), { headers: { authorization: `Bearer ${state.token}` }, signal: AbortSignal.timeout(20_000) });
|
|
79
|
+
let local = null;
|
|
80
|
+
try { local = JSON.parse(await readFile(STATUS_FILE, "utf8")); } catch {}
|
|
81
|
+
if (!response.ok) { console.log(`Server answered HTTP ${response.status}`); return; }
|
|
82
|
+
console.log(JSON.stringify({ ...(await response.json()), local }, null, 2));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function pause() {
|
|
86
|
+
await protectConnectHome();
|
|
87
|
+
await writeFile(PAUSE_FILE, "paused\n", { mode: 0o600 });
|
|
88
|
+
console.log("Heny Connect is pausing.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resume() {
|
|
92
|
+
await unlink(PAUSE_FILE).catch(() => undefined);
|
|
93
|
+
console.log("Heny Connect is resuming.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireWindows() {
|
|
97
|
+
if (platform() === "win32") return true;
|
|
98
|
+
console.error("The system tray is available on Windows.");
|
|
99
|
+
process.exitCode = 2;
|
|
100
|
+
return false;
|
|
71
101
|
}
|
|
72
102
|
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
function trayArgs(mode) {
|
|
104
|
+
const values = ["-NoLogo", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", TRAY_SCRIPT, "-NodePath", process.execPath, "-CliPath", CLI_FILE, "-HomePath", CONNECT_HOME];
|
|
105
|
+
if (mode === "install") values.push("-Install");
|
|
106
|
+
if (mode === "uninstall") values.push("-Uninstall");
|
|
107
|
+
return values;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function tray() {
|
|
111
|
+
if (!requireWindows()) return;
|
|
112
|
+
if (!await loadState()) { console.error("Pair this computer before starting the tray."); process.exitCode = 2; return; }
|
|
113
|
+
const child = spawn("powershell.exe", trayArgs("tray"), { detached: true, stdio: "ignore", windowsHide: true });
|
|
114
|
+
child.unref();
|
|
115
|
+
console.log("Heny Connect is running in the system tray.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function install() {
|
|
119
|
+
if (!requireWindows()) return;
|
|
120
|
+
if (!await loadState()) { console.error("Pair this computer before installing the tray."); process.exitCode = 2; return; }
|
|
121
|
+
if (CLI_FILE.includes("\\_npx\\")) { console.error("Install the package globally before enabling start at sign-in: npm.cmd install -g @xevy/heny-connect"); process.exitCode = 2; return; }
|
|
122
|
+
const result = spawnSync("powershell.exe", trayArgs("install"), { encoding: "utf8", windowsHide: true });
|
|
123
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
124
|
+
if (result.status !== 0) { if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.status || 1; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function uninstall() {
|
|
128
|
+
if (!requireWindows()) return;
|
|
129
|
+
const result = spawnSync("powershell.exe", trayArgs("uninstall"), { encoding: "utf8", windowsHide: true });
|
|
130
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
131
|
+
if (result.status !== 0) { if (result.stderr) process.stderr.write(result.stderr); process.exitCode = result.status || 1; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const help = async () => console.log("Commands: pair --code <6 digits> --server <url> [--run] | run | status | pause | resume | heartbeat --state <state> | tray | install | uninstall");
|
|
135
|
+
const commands = { pair, run, status, pause, resume, heartbeat: heartbeatOnce, tray, install, uninstall, help };
|
|
136
|
+
try {
|
|
137
|
+
await (commands[command] || help)();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error(error.message);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { platform } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { CdpSession } from "./cdp.mjs";
|
|
8
|
+
import { parsePublicUrl, resolvePublicUrl } from "./network-policy.mjs";
|
|
9
|
+
import { createValidatingProxy } from "./validating-proxy.mjs";
|
|
10
|
+
|
|
11
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
const DENIED_PERMISSIONS = ["geolocation", "notifications", "midi", "midiSysex", "camera", "microphone", "backgroundSync", "sensors", "clipboardReadWrite", "clipboardSanitizedWrite", "paymentHandler", "idleDetection", "localFonts", "windowManagement"];
|
|
13
|
+
|
|
14
|
+
async function randomPort() {
|
|
15
|
+
const server = net.createServer();
|
|
16
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
|
|
17
|
+
const port = server.address().port;
|
|
18
|
+
await new Promise((resolve) => server.close(resolve));
|
|
19
|
+
return port;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function browserCandidates() {
|
|
23
|
+
const local = process.env.LOCALAPPDATA;
|
|
24
|
+
const programs = process.env.PROGRAMFILES;
|
|
25
|
+
const programs86 = process.env["PROGRAMFILES(X86)"];
|
|
26
|
+
return [
|
|
27
|
+
process.env.HENY_BROWSER_PATH,
|
|
28
|
+
local && join(local, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
29
|
+
programs && join(programs, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
30
|
+
programs86 && join(programs86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
31
|
+
local && join(local, "Google", "Chrome", "Application", "chrome.exe"),
|
|
32
|
+
programs && join(programs, "Google", "Chrome", "Application", "chrome.exe"),
|
|
33
|
+
programs86 && join(programs86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findBrowser() {
|
|
38
|
+
const browser = browserCandidates().find((candidate) => existsSync(candidate));
|
|
39
|
+
if (!browser) throw Object.assign(new Error("Install Microsoft Edge or Google Chrome."), { code: "browser_missing" });
|
|
40
|
+
return browser;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class BrowserController {
|
|
44
|
+
constructor({ home, browserPath = findBrowser(), lookup } = {}) {
|
|
45
|
+
this.home = home;
|
|
46
|
+
this.browserPath = browserPath;
|
|
47
|
+
this.lookup = lookup;
|
|
48
|
+
this.process = null;
|
|
49
|
+
this.proxy = null;
|
|
50
|
+
this.session = null;
|
|
51
|
+
this.targetId = null;
|
|
52
|
+
this.mainFrameId = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async start() {
|
|
56
|
+
if (this.session) return;
|
|
57
|
+
const profile = join(this.home, "browser-profile");
|
|
58
|
+
await mkdir(profile, { recursive: true });
|
|
59
|
+
this.proxy = await createValidatingProxy({ lookup: this.lookup });
|
|
60
|
+
const port = await randomPort();
|
|
61
|
+
const args = [
|
|
62
|
+
`--remote-debugging-address=127.0.0.1`,
|
|
63
|
+
`--remote-debugging-port=${port}`,
|
|
64
|
+
`--user-data-dir=${profile}`,
|
|
65
|
+
`--proxy-server=http://127.0.0.1:${this.proxy.port}`,
|
|
66
|
+
"--proxy-bypass-list=<-loopback>",
|
|
67
|
+
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
|
|
68
|
+
"--disable-quic",
|
|
69
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
70
|
+
"--no-first-run",
|
|
71
|
+
"--no-default-browser-check",
|
|
72
|
+
"--disable-sync",
|
|
73
|
+
"--disable-background-networking",
|
|
74
|
+
"--disable-component-update",
|
|
75
|
+
"--disable-features=ExternalProtocolDialog",
|
|
76
|
+
"--disable-notifications",
|
|
77
|
+
"--disable-extensions",
|
|
78
|
+
"--disable-session-crashed-bubble",
|
|
79
|
+
"--no-service-autorun",
|
|
80
|
+
"--new-window",
|
|
81
|
+
"about:blank",
|
|
82
|
+
];
|
|
83
|
+
this.process = spawn(this.browserPath, args, { stdio: "ignore", windowsHide: false });
|
|
84
|
+
const browserProcess = this.process;
|
|
85
|
+
this.process.once("exit", () => {
|
|
86
|
+
if (this.process === browserProcess) this.process = null;
|
|
87
|
+
if (this.session) { const session = this.session; this.session = null; session.close(); }
|
|
88
|
+
if (this.proxy) { const proxy = this.proxy; this.proxy = null; void proxy.close().catch(() => undefined); }
|
|
89
|
+
});
|
|
90
|
+
let version;
|
|
91
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
92
|
+
try {
|
|
93
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) });
|
|
94
|
+
if (response.ok) { version = await response.json(); break; }
|
|
95
|
+
} catch {}
|
|
96
|
+
await sleep(250);
|
|
97
|
+
}
|
|
98
|
+
if (!version) { await this.stop(); throw Object.assign(new Error("The dedicated browser did not start."), { code: "browser_start_failed" }); }
|
|
99
|
+
let targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
100
|
+
let target = targets.find((entry) => entry.type === "page");
|
|
101
|
+
if (!target) target = await (await fetch(`http://127.0.0.1:${port}/json/new?about%3Ablank`, { method: "PUT" })).json();
|
|
102
|
+
this.targetId = target.id;
|
|
103
|
+
this.session = await CdpSession.open(target.webSocketDebuggerUrl);
|
|
104
|
+
const connectedSession = this.session;
|
|
105
|
+
this.session.onClose(() => {
|
|
106
|
+
if (this.session !== connectedSession) return;
|
|
107
|
+
this.session = null;
|
|
108
|
+
this.targetId = null;
|
|
109
|
+
this.mainFrameId = null;
|
|
110
|
+
void this.stop();
|
|
111
|
+
});
|
|
112
|
+
await Promise.all([
|
|
113
|
+
this.session.send("Page.enable"),
|
|
114
|
+
this.session.send("Runtime.enable"),
|
|
115
|
+
this.session.send("Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] }),
|
|
116
|
+
this.session.send("Target.setDiscoverTargets", { discover: true }),
|
|
117
|
+
this.session.send("Browser.setDownloadBehavior", { behavior: "deny" }).catch(() => undefined),
|
|
118
|
+
this.session.send("Browser.resetPermissions").catch(() => undefined),
|
|
119
|
+
this.session.send("Page.setInterceptFileChooserDialog", { enabled: true }).catch(() => undefined),
|
|
120
|
+
...DENIED_PERMISSIONS.map((name) => this.session.send("Browser.setPermission", { permission: { name }, setting: "denied" }).catch(() => undefined)),
|
|
121
|
+
]);
|
|
122
|
+
const frameTree = await this.session.send("Page.getFrameTree");
|
|
123
|
+
this.mainFrameId = frameTree.frameTree.frame.id;
|
|
124
|
+
this.session.on("Fetch.requestPaused", async ({ requestId, request }) => {
|
|
125
|
+
try {
|
|
126
|
+
await resolvePublicUrl(request.url, this.lookup);
|
|
127
|
+
await this.session?.send("Fetch.continueRequest", { requestId });
|
|
128
|
+
} catch {
|
|
129
|
+
await this.session?.send("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" }).catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
this.session.on("Page.javascriptDialogOpening", () => this.session?.send("Page.handleJavaScriptDialog", { accept: false }).catch(() => undefined));
|
|
133
|
+
this.session.on("Page.fileChooserOpened", () => undefined);
|
|
134
|
+
this.session.on("Page.frameRequestedNavigation", ({ frameId, url }) => {
|
|
135
|
+
let protocol = "";
|
|
136
|
+
try { protocol = new URL(url).protocol; } catch {}
|
|
137
|
+
const external = !["http:", "https:", "about:", "data:", "blob:"].includes(protocol);
|
|
138
|
+
const blockedMainFrame = frameId === this.mainFrameId && !["http:", "https:"].includes(protocol);
|
|
139
|
+
if (external || blockedMainFrame) {
|
|
140
|
+
void this.session?.send("Page.stopLoading").catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
this.session.on("Target.targetCreated", ({ targetInfo }) => {
|
|
144
|
+
if (targetInfo?.type === "page" && targetInfo.targetId !== this.targetId) void this.session?.send("Target.closeTarget", { targetId: targetInfo.targetId }).catch(() => undefined);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async execute(action, payload, { signal, timeoutMs }) {
|
|
149
|
+
if (signal?.aborted) throw Object.assign(new Error("Command cancelled."), { code: "command_cancelled" });
|
|
150
|
+
await this.start();
|
|
151
|
+
if (signal?.aborted) throw Object.assign(new Error("Command cancelled."), { code: "command_cancelled" });
|
|
152
|
+
const onAbort = () => { void this.session?.send("Page.stopLoading").catch(() => undefined); };
|
|
153
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
154
|
+
try {
|
|
155
|
+
const operation = action === "browser.navigate" ? this.navigate(payload.url, timeoutMs, signal)
|
|
156
|
+
: action === "browser.read" ? this.read()
|
|
157
|
+
: action === "browser.screenshot" ? this.screenshot()
|
|
158
|
+
: Promise.reject(Object.assign(new Error("Unsupported command."), { code: "unsupported_action" }));
|
|
159
|
+
const cancelled = new Promise((_, reject) => signal?.addEventListener("abort", () => reject(Object.assign(new Error("Command cancelled."), { code: "command_cancelled" })), { once: true }));
|
|
160
|
+
return await Promise.race([operation, cancelled]);
|
|
161
|
+
} finally {
|
|
162
|
+
signal?.removeEventListener("abort", onAbort);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async pageIdentity() {
|
|
167
|
+
const result = await this.session.send("Runtime.evaluate", {
|
|
168
|
+
expression: "({title:String(document.title||'').slice(0,1000),url:String(location.href)})",
|
|
169
|
+
returnByValue: true,
|
|
170
|
+
});
|
|
171
|
+
return result.result.value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async navigate(value, timeoutMs, signal) {
|
|
175
|
+
const url = parsePublicUrl(value);
|
|
176
|
+
await resolvePublicUrl(url.href, this.lookup);
|
|
177
|
+
const navigationAbort = new AbortController();
|
|
178
|
+
const abortNavigation = () => navigationAbort.abort();
|
|
179
|
+
signal?.addEventListener("abort", abortNavigation, { once: true });
|
|
180
|
+
const loaded = this.session.waitFor("Page.loadEventFired", timeoutMs, navigationAbort.signal).then(
|
|
181
|
+
() => ({ error: null }),
|
|
182
|
+
(error) => ({ error }),
|
|
183
|
+
);
|
|
184
|
+
try {
|
|
185
|
+
const result = await this.session.send("Page.navigate", { url: url.href }, timeoutMs);
|
|
186
|
+
if (result.errorText) throw Object.assign(new Error("Navigation failed."), { code: "navigation_failed" });
|
|
187
|
+
const loadOutcome = await loaded;
|
|
188
|
+
if (loadOutcome.error) throw loadOutcome.error;
|
|
189
|
+
const identity = await this.pageIdentity();
|
|
190
|
+
parsePublicUrl(identity.url);
|
|
191
|
+
return identity;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
navigationAbort.abort();
|
|
194
|
+
await loaded;
|
|
195
|
+
throw error;
|
|
196
|
+
} finally {
|
|
197
|
+
signal?.removeEventListener("abort", abortNavigation);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async read() {
|
|
202
|
+
const result = await this.session.send("Runtime.evaluate", {
|
|
203
|
+
expression: `(()=>{const raw=(document.body?.innerText||"").replace(/\\r/g,"").replace(/[ \\t]+/g," ").replace(/\\n{3,}/g,"\\n\\n").trim();return {title:String(document.title||"").slice(0,1000),url:String(location.href),text:raw.slice(0,50000),truncated:raw.length>50000}})()`,
|
|
204
|
+
returnByValue: true,
|
|
205
|
+
});
|
|
206
|
+
parsePublicUrl(result.result.value.url);
|
|
207
|
+
return result.result.value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async screenshot() {
|
|
211
|
+
const identity = await this.pageIdentity();
|
|
212
|
+
parsePublicUrl(identity.url);
|
|
213
|
+
const metrics = await this.session.send("Page.getLayoutMetrics");
|
|
214
|
+
const viewport = metrics.cssVisualViewport || metrics.visualViewport;
|
|
215
|
+
const result = await this.session.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
|
|
216
|
+
if (Buffer.byteLength(result.data, "base64") > 2 * 1024 * 1024) throw Object.assign(new Error("Screenshot is too large."), { code: "screenshot_too_large" });
|
|
217
|
+
return { ...identity, mimeType: "image/png", dataBase64: result.data, width: Math.round(viewport.clientWidth), height: Math.round(viewport.clientHeight) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async stop() {
|
|
221
|
+
const session = this.session;
|
|
222
|
+
this.session = null;
|
|
223
|
+
this.mainFrameId = null;
|
|
224
|
+
const browserProcess = this.process;
|
|
225
|
+
this.process = null;
|
|
226
|
+
if (browserProcess) {
|
|
227
|
+
const exited = browserProcess.exitCode !== null;
|
|
228
|
+
const exit = exited ? Promise.resolve(true) : new Promise((resolve) => browserProcess.once("exit", () => resolve(true)));
|
|
229
|
+
if (session) await session.send("Browser.close", {}, 2_000).catch(() => undefined);
|
|
230
|
+
const closed = exited || await Promise.race([exit, sleep(3_000).then(() => false)]);
|
|
231
|
+
if (!closed) {
|
|
232
|
+
if (platform() === "win32" && browserProcess.pid) spawnSync("taskkill.exe", ["/PID", String(browserProcess.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
233
|
+
else browserProcess.kill();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
session?.close();
|
|
237
|
+
if (this.proxy) {
|
|
238
|
+
await this.proxy.close().catch(() => undefined);
|
|
239
|
+
this.proxy = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
package/lib/cdp.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export class CdpSession {
|
|
2
|
+
constructor(socket) {
|
|
3
|
+
this.socket = socket;
|
|
4
|
+
this.id = 0;
|
|
5
|
+
this.pending = new Map();
|
|
6
|
+
this.listeners = new Map();
|
|
7
|
+
this.closeListeners = new Set();
|
|
8
|
+
this.closed = false;
|
|
9
|
+
socket.onmessage = (event) => this.onMessage(JSON.parse(typeof event.data === "string" ? event.data : event.data.toString()));
|
|
10
|
+
socket.onerror = () => this.onClosed();
|
|
11
|
+
socket.onclose = () => this.onClosed();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
onClosed() {
|
|
15
|
+
if (this.closed) return;
|
|
16
|
+
this.closed = true;
|
|
17
|
+
const error = Object.assign(new Error("Dedicated browser closed."), { code: "browser_closed" });
|
|
18
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
19
|
+
this.pending.clear();
|
|
20
|
+
for (const listener of this.closeListeners) void listener(error);
|
|
21
|
+
this.closeListeners.clear();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static async open(url) {
|
|
25
|
+
if (typeof WebSocket !== "function") throw Object.assign(new Error("Heny Connect requires Node 22 or newer."), { code: "node_unsupported" });
|
|
26
|
+
const socket = new WebSocket(url);
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
socket.onopen = resolve;
|
|
29
|
+
socket.onerror = () => reject(Object.assign(new Error("Dedicated browser connection failed."), { code: "browser_connection_failed" }));
|
|
30
|
+
});
|
|
31
|
+
return new CdpSession(socket);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onMessage(message) {
|
|
35
|
+
if (message.id && this.pending.has(message.id)) {
|
|
36
|
+
const pending = this.pending.get(message.id);
|
|
37
|
+
this.pending.delete(message.id);
|
|
38
|
+
if (message.error) pending.reject(Object.assign(new Error(message.error.message), { code: "browser_protocol_error" }));
|
|
39
|
+
else pending.resolve(message.result);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (message.method) {
|
|
43
|
+
for (const listener of this.listeners.get(message.method) || []) void listener(message.params || {});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
on(method, listener) {
|
|
48
|
+
const entries = this.listeners.get(method) || [];
|
|
49
|
+
entries.push(listener);
|
|
50
|
+
this.listeners.set(method, entries);
|
|
51
|
+
return () => this.listeners.set(method, entries.filter((entry) => entry !== listener));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onClose(listener) {
|
|
55
|
+
if (this.closed) { void listener(Object.assign(new Error("Dedicated browser closed."), { code: "browser_closed" })); return () => undefined; }
|
|
56
|
+
this.closeListeners.add(listener);
|
|
57
|
+
return () => this.closeListeners.delete(listener);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
send(method, params = {}, timeoutMs = 20_000) {
|
|
61
|
+
if (this.closed) return Promise.reject(Object.assign(new Error("Dedicated browser closed."), { code: "browser_closed" }));
|
|
62
|
+
const id = ++this.id;
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
this.pending.delete(id);
|
|
66
|
+
reject(Object.assign(new Error("Dedicated browser timed out."), { code: "browser_timeout" }));
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
this.pending.set(id, {
|
|
69
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
70
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
71
|
+
});
|
|
72
|
+
try { this.socket.send(JSON.stringify({ id, method, params })); } catch { this.onClosed(); }
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
waitFor(method, timeoutMs, signal) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const done = (error, value) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
remove();
|
|
81
|
+
signal?.removeEventListener("abort", aborted);
|
|
82
|
+
error ? reject(error) : resolve(value);
|
|
83
|
+
};
|
|
84
|
+
const remove = this.on(method, (params) => done(null, params));
|
|
85
|
+
const timer = setTimeout(() => done(Object.assign(new Error("Dedicated browser timed out."), { code: "browser_timeout" })), timeoutMs);
|
|
86
|
+
const aborted = () => done(Object.assign(new Error("Command cancelled."), { code: "command_cancelled" }));
|
|
87
|
+
if (signal?.aborted) queueMicrotask(aborted);
|
|
88
|
+
else signal?.addEventListener("abort", aborted, { once: true });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close() {
|
|
93
|
+
try { this.socket.close(); } finally { this.onClosed(); }
|
|
94
|
+
}
|
|
95
|
+
}
|