@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
|
|
4
|
+
function ipv4Number(address) {
|
|
5
|
+
const parts = address.split(".").map(Number);
|
|
6
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
7
|
+
return parts.reduce((value, part) => (value * 256) + part, 0) >>> 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function ipv4In(address, base, bits) {
|
|
11
|
+
const value = ipv4Number(address);
|
|
12
|
+
const start = ipv4Number(base);
|
|
13
|
+
if (value === null || start === null) return false;
|
|
14
|
+
if (bits === 0) return true;
|
|
15
|
+
const mask = (0xffffffff << (32 - bits)) >>> 0;
|
|
16
|
+
return (value & mask) === (start & mask);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function expandIpv6(address) {
|
|
20
|
+
let value = address.toLowerCase().split("%")[0];
|
|
21
|
+
if (value.includes(".")) {
|
|
22
|
+
const lastColon = value.lastIndexOf(":");
|
|
23
|
+
const v4 = ipv4Number(value.slice(lastColon + 1));
|
|
24
|
+
if (v4 === null) return null;
|
|
25
|
+
value = `${value.slice(0, lastColon)}:${((v4 >>> 16) & 0xffff).toString(16)}:${(v4 & 0xffff).toString(16)}`;
|
|
26
|
+
}
|
|
27
|
+
const halves = value.split("::");
|
|
28
|
+
if (halves.length > 2) return null;
|
|
29
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
30
|
+
const right = halves[1] ? halves[1].split(":") : [];
|
|
31
|
+
const fill = 8 - left.length - right.length;
|
|
32
|
+
if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
|
|
33
|
+
const words = [...left, ...Array(fill).fill("0"), ...right];
|
|
34
|
+
if (words.length !== 8 || words.some((word) => !/^[0-9a-f]{1,4}$/.test(word))) return null;
|
|
35
|
+
return words.reduce((result, word) => (result << 16n) | BigInt(parseInt(word, 16)), 0n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ipv6In(address, base, bits) {
|
|
39
|
+
const value = expandIpv6(address);
|
|
40
|
+
const start = expandIpv6(base);
|
|
41
|
+
if (value === null || start === null) return false;
|
|
42
|
+
if (bits === 0) return true;
|
|
43
|
+
const shift = 128n - BigInt(bits);
|
|
44
|
+
return (value >> shift) === (start >> shift);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isBlockedAddress(address) {
|
|
48
|
+
const version = isIP(address);
|
|
49
|
+
if (version === 4) {
|
|
50
|
+
return [
|
|
51
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
52
|
+
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
|
53
|
+
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
|
|
54
|
+
["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
55
|
+
].some(([base, bits]) => ipv4In(address, base, bits));
|
|
56
|
+
}
|
|
57
|
+
if (version === 6) {
|
|
58
|
+
const mapped = address.toLowerCase().match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
59
|
+
if (mapped) return isBlockedAddress(mapped[1]);
|
|
60
|
+
if (ipv6In(address, "::ffff:0:0", 96)) {
|
|
61
|
+
const value = expandIpv6(address);
|
|
62
|
+
const low = Number(value & 0xffffffffn) >>> 0;
|
|
63
|
+
return isBlockedAddress(`${low >>> 24}.${(low >>> 16) & 255}.${(low >>> 8) & 255}.${low & 255}`);
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
["::", 96], ["::1", 128], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64],
|
|
67
|
+
["2001::", 32], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20], ["5f00::", 16],
|
|
68
|
+
["fc00::", 7], ["fec0::", 10], ["fe80::", 10], ["ff00::", 8],
|
|
69
|
+
].some(([base, bits]) => ipv6In(address, base, bits));
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parsePublicUrl(value) {
|
|
75
|
+
let url;
|
|
76
|
+
try { url = new URL(value); } catch { throw Object.assign(new Error("Destination URL is invalid."), { code: "destination_invalid" }); }
|
|
77
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
|
78
|
+
throw Object.assign(new Error("Destination must use HTTP(S) without credentials."), { code: "destination_blocked" });
|
|
79
|
+
}
|
|
80
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
81
|
+
if (![80, 443].includes(port)) throw Object.assign(new Error("Destination port is blocked."), { code: "destination_blocked" });
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function resolvePublicHost(hostname, lookup = dnsLookup) {
|
|
86
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
87
|
+
if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host === "metadata.google.internal") {
|
|
88
|
+
throw Object.assign(new Error("Destination is blocked."), { code: "destination_blocked" });
|
|
89
|
+
}
|
|
90
|
+
const literal = isIP(host);
|
|
91
|
+
const answers = literal ? [{ address: host, family: literal }] : await lookup(host, { all: true, verbatim: true });
|
|
92
|
+
if (!answers.length || answers.some(({ address }) => isBlockedAddress(address))) {
|
|
93
|
+
throw Object.assign(new Error("Destination is blocked."), { code: "destination_blocked" });
|
|
94
|
+
}
|
|
95
|
+
return answers[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function resolvePublicUrl(value, lookup = dnsLookup) {
|
|
99
|
+
const url = parsePublicUrl(value);
|
|
100
|
+
const answer = await resolvePublicHost(url.hostname, lookup);
|
|
101
|
+
return { url, ...answer };
|
|
102
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const CONNECT_HOME = process.env.HENY_CONNECT_HOME || (platform() === "win32" && process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "Heny Connect") : homedir());
|
|
7
|
+
export const STATE_FILE = join(CONNECT_HOME, ".heny-connect.json");
|
|
8
|
+
export const STATUS_FILE = join(CONNECT_HOME, "worker-status.json");
|
|
9
|
+
export const PAUSE_FILE = join(CONNECT_HOME, "paused");
|
|
10
|
+
export const STOP_FILE = join(CONNECT_HOME, "stopping");
|
|
11
|
+
export const LOCK_FILE = join(CONNECT_HOME, "worker.lock");
|
|
12
|
+
|
|
13
|
+
export async function protectConnectHome() {
|
|
14
|
+
await mkdir(CONNECT_HOME, { recursive: true });
|
|
15
|
+
if (platform() === "win32") {
|
|
16
|
+
const script = [
|
|
17
|
+
"$target=$env:HENY_ACL_TARGET",
|
|
18
|
+
"$acl=New-Object System.Security.AccessControl.DirectorySecurity",
|
|
19
|
+
"$acl.SetAccessRuleProtection($true,$false)",
|
|
20
|
+
"$inherit=[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'",
|
|
21
|
+
"$prop=[System.Security.AccessControl.PropagationFlags]::None",
|
|
22
|
+
"$allow=[System.Security.AccessControl.AccessControlType]::Allow",
|
|
23
|
+
"$full=[System.Security.AccessControl.FileSystemRights]::FullControl",
|
|
24
|
+
"$user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User",
|
|
25
|
+
"$system=[System.Security.Principal.SecurityIdentifier]::new('S-1-5-18')",
|
|
26
|
+
"$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($user,$full,$inherit,$prop,$allow))",
|
|
27
|
+
"$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($system,$full,$inherit,$prop,$allow))",
|
|
28
|
+
"Set-Acl -LiteralPath $target -AclObject $acl",
|
|
29
|
+
].join(";");
|
|
30
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, stdio: "ignore", env: { ...process.env, HENY_ACL_TARGET: CONNECT_HOME } });
|
|
31
|
+
if (result.status !== 0) throw Object.assign(new Error("Heny Connect could not protect its local state."), { code: "acl_failed" });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadState() {
|
|
36
|
+
try { return JSON.parse(await readFile(STATE_FILE, "utf8")); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function saveState(state) {
|
|
40
|
+
await protectConnectHome();
|
|
41
|
+
const temporary = `${STATE_FILE}.tmp`;
|
|
42
|
+
await writeFile(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
43
|
+
await rename(temporary, STATE_FILE);
|
|
44
|
+
await chmod(STATE_FILE, 0o600).catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function clearState() {
|
|
48
|
+
await unlink(STATE_FILE).catch(() => undefined);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function writeStatus(status) {
|
|
52
|
+
await mkdir(CONNECT_HOME, { recursive: true });
|
|
53
|
+
await writeFile(STATUS_FILE, JSON.stringify({ ...status, updatedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { resolvePublicHost, resolvePublicUrl } from "./network-policy.mjs";
|
|
4
|
+
|
|
5
|
+
function safeDestroy(stream) {
|
|
6
|
+
try { stream.destroy(); } catch {}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function createValidatingProxy(options = {}) {
|
|
10
|
+
const lookup = options.lookup;
|
|
11
|
+
const server = http.createServer(async (request, response) => {
|
|
12
|
+
try {
|
|
13
|
+
const { url, address, family } = await resolvePublicUrl(request.url, lookup);
|
|
14
|
+
const headers = { ...request.headers, host: url.host };
|
|
15
|
+
delete headers["proxy-connection"];
|
|
16
|
+
const upstream = http.request({
|
|
17
|
+
host: address,
|
|
18
|
+
family,
|
|
19
|
+
port: Number(url.port || 80),
|
|
20
|
+
method: request.method,
|
|
21
|
+
path: `${url.pathname}${url.search}`,
|
|
22
|
+
headers,
|
|
23
|
+
}, (incoming) => {
|
|
24
|
+
response.writeHead(incoming.statusCode || 502, incoming.statusMessage, incoming.headers);
|
|
25
|
+
incoming.pipe(response);
|
|
26
|
+
});
|
|
27
|
+
upstream.on("error", () => {
|
|
28
|
+
if (!response.headersSent) response.writeHead(502);
|
|
29
|
+
response.end();
|
|
30
|
+
});
|
|
31
|
+
request.pipe(upstream);
|
|
32
|
+
} catch {
|
|
33
|
+
response.writeHead(403, { "content-type": "text/plain", connection: "close" });
|
|
34
|
+
response.end("Destination blocked");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
server.on("connect", async (request, client, head) => {
|
|
39
|
+
try {
|
|
40
|
+
const authority = request.url || "";
|
|
41
|
+
const match = authority.match(/^\[([^\]]+)]:(\d+)$/) || authority.match(/^([^:]+):(\d+)$/);
|
|
42
|
+
if (!match) throw new Error("Invalid authority");
|
|
43
|
+
const host = match[1];
|
|
44
|
+
const port = Number(match[2]);
|
|
45
|
+
if (port !== 443 && port !== 80) throw new Error("Port blocked");
|
|
46
|
+
const { address, family } = await resolvePublicHost(host, lookup);
|
|
47
|
+
const upstream = net.connect({ host: address, family, port }, () => {
|
|
48
|
+
client.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: Heny\r\n\r\n");
|
|
49
|
+
if (head.length) upstream.write(head);
|
|
50
|
+
client.pipe(upstream);
|
|
51
|
+
upstream.pipe(client);
|
|
52
|
+
});
|
|
53
|
+
upstream.on("error", () => safeDestroy(client));
|
|
54
|
+
client.on("error", () => safeDestroy(upstream));
|
|
55
|
+
} catch {
|
|
56
|
+
client.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
57
|
+
safeDestroy(client);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
server.once("error", reject);
|
|
63
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
64
|
+
});
|
|
65
|
+
const address = server.address();
|
|
66
|
+
return {
|
|
67
|
+
port: address.port,
|
|
68
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
69
|
+
};
|
|
70
|
+
}
|
package/lib/worker.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { open, readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import { BrowserController } from "./browser-controller.mjs";
|
|
3
|
+
import { clearState, CONNECT_HOME, LOCK_FILE, PAUSE_FILE, STOP_FILE, writeStatus } from "./state.mjs";
|
|
4
|
+
|
|
5
|
+
export const CAPABILITIES = ["browser.navigate", "browser.read", "browser.screenshot"];
|
|
6
|
+
const LEASE_MS = 10_000;
|
|
7
|
+
const POLL_MS = 2_000;
|
|
8
|
+
const HEARTBEAT_MS = 30_000;
|
|
9
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
const settleWithin = (promise, milliseconds) => new Promise((resolve) => {
|
|
11
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
12
|
+
timer.unref?.();
|
|
13
|
+
Promise.resolve(promise).then((value) => { clearTimeout(timer); resolve(value); }, () => { clearTimeout(timer); resolve(undefined); });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const attemptWithin = (promise, milliseconds) => new Promise((resolve) => {
|
|
17
|
+
const timer = setTimeout(() => resolve({ acknowledged: false }), milliseconds);
|
|
18
|
+
Promise.resolve(promise).then(
|
|
19
|
+
(value) => { clearTimeout(timer); resolve({ acknowledged: true, value }); },
|
|
20
|
+
(error) => { clearTimeout(timer); resolve({ acknowledged: false, error }); },
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function completeWithRetry(api, command, claimToken, status, result, error, cutoff) {
|
|
25
|
+
while (Date.now() < cutoff) {
|
|
26
|
+
const remaining = cutoff - Date.now();
|
|
27
|
+
const outcome = await attemptWithin(api.complete(command, claimToken, status, result, error), Math.min(2_000, remaining));
|
|
28
|
+
if (outcome.acknowledged) return true;
|
|
29
|
+
if (outcome.error?.status === 401) throw outcome.error;
|
|
30
|
+
if (outcome.error && [400, 409].includes(outcome.error.status)) return false;
|
|
31
|
+
const delay = Math.min(250, Math.max(0, cutoff - Date.now()));
|
|
32
|
+
if (delay) await sleep(delay);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizedCode(error) {
|
|
38
|
+
const value = typeof error?.code === "string" ? error.code : "worker_error";
|
|
39
|
+
return /^[a-z0-9_]{1,40}$/.test(value) ? value : "worker_error";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function paused() {
|
|
43
|
+
try { await readFile(PAUSE_FILE); return true; } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function shutdownRequested() {
|
|
47
|
+
try { await readFile(STOP_FILE); return true; } catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function acquireLock() {
|
|
51
|
+
try {
|
|
52
|
+
const file = await open(LOCK_FILE, "wx", 0o600);
|
|
53
|
+
await file.writeFile(String(process.pid));
|
|
54
|
+
return async () => { await file.close(); await unlink(LOCK_FILE).catch(() => undefined); };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code !== "EEXIST") throw error;
|
|
57
|
+
let pid = 0;
|
|
58
|
+
try { pid = Number(await readFile(LOCK_FILE, "utf8")); process.kill(pid, 0); } catch {
|
|
59
|
+
await unlink(LOCK_FILE).catch(() => undefined);
|
|
60
|
+
return acquireLock();
|
|
61
|
+
}
|
|
62
|
+
throw Object.assign(new Error("Heny Connect is already running."), { code: "worker_running" });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class DeviceApi {
|
|
67
|
+
constructor(state) { this.state = state; }
|
|
68
|
+
|
|
69
|
+
async request(path, body = {}) {
|
|
70
|
+
const response = await fetch(new URL(path, this.state.server), {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.state.token}` },
|
|
73
|
+
body: JSON.stringify(body),
|
|
74
|
+
signal: AbortSignal.timeout(20_000),
|
|
75
|
+
});
|
|
76
|
+
if (response.status === 401) {
|
|
77
|
+
await clearState();
|
|
78
|
+
throw Object.assign(new Error("Registration was revoked. Pair this computer again."), { code: "device_revoked", status: 401 });
|
|
79
|
+
}
|
|
80
|
+
if (response.status === 204) return null;
|
|
81
|
+
let parsed = null;
|
|
82
|
+
try { parsed = await response.json(); } catch {}
|
|
83
|
+
if (!response.ok) throw Object.assign(new Error(parsed?.error?.message || `Server answered HTTP ${response.status}`), { code: parsed?.error?.code || "server_error", status: response.status });
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
heartbeat(state, browserReady, current) {
|
|
88
|
+
return this.request("/api/devices/heartbeat", {
|
|
89
|
+
state,
|
|
90
|
+
browserReady,
|
|
91
|
+
capabilities: CAPABILITIES,
|
|
92
|
+
...(current ? { currentCommandId: current.id, currentAction: current.action } : {}),
|
|
93
|
+
detailCode: current ? "command_running" : state,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
claim() { return this.request("/api/devices/commands/claim"); }
|
|
98
|
+
lease(command, claimToken) { return this.request(`/api/devices/commands/${command.id}/lease`, { claimToken, attempt: command.attempt }); }
|
|
99
|
+
complete(command, claimToken, status, result, error) {
|
|
100
|
+
return this.request(`/api/devices/commands/${command.id}/complete`, {
|
|
101
|
+
claimToken,
|
|
102
|
+
attempt: command.attempt,
|
|
103
|
+
status,
|
|
104
|
+
...(result !== undefined ? { result } : {}),
|
|
105
|
+
...(error ? { error } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function runWorker(state, options = {}) {
|
|
111
|
+
await unlink(STOP_FILE).catch(() => undefined);
|
|
112
|
+
const releaseLock = await acquireLock();
|
|
113
|
+
const api = options.api || new DeviceApi(state);
|
|
114
|
+
const browser = options.browser || new BrowserController({ home: CONNECT_HOME });
|
|
115
|
+
let active = null;
|
|
116
|
+
let stopped = false;
|
|
117
|
+
let nextHeartbeat = 0;
|
|
118
|
+
let pauseDeadline = 0;
|
|
119
|
+
const stop = () => { stopped = true; active?.controller.abort(); void browser.stop().catch(() => undefined); };
|
|
120
|
+
process.once("SIGINT", stop);
|
|
121
|
+
process.once("SIGTERM", stop);
|
|
122
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
123
|
+
let watchBusy = false;
|
|
124
|
+
let pauseHandled = false;
|
|
125
|
+
const localWatch = setInterval(() => {
|
|
126
|
+
if (watchBusy) return;
|
|
127
|
+
watchBusy = true;
|
|
128
|
+
void Promise.all([paused(), shutdownRequested()]).then(async ([isPaused, shutdown]) => {
|
|
129
|
+
if (shutdown) stopped = true;
|
|
130
|
+
if (isPaused || shutdown) {
|
|
131
|
+
active?.controller.abort();
|
|
132
|
+
if (!pauseHandled) {
|
|
133
|
+
pauseHandled = true;
|
|
134
|
+
await browser.stop().catch(() => undefined);
|
|
135
|
+
await writeStatus({ state: isPaused ? "paused" : "offline", currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
pauseHandled = false;
|
|
139
|
+
}
|
|
140
|
+
}).finally(() => { watchBusy = false; });
|
|
141
|
+
}, 250);
|
|
142
|
+
|
|
143
|
+
async function report(workerState, browserReady, current) {
|
|
144
|
+
await writeStatus({ state: workerState, currentCommandId: current?.id ?? null, currentAction: current?.action ?? null });
|
|
145
|
+
await api.heartbeat(workerState, browserReady, current);
|
|
146
|
+
nextHeartbeat = Date.now() + HEARTBEAT_MS;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await writeStatus({ state: "browser_starting", currentCommandId: null, currentAction: null });
|
|
151
|
+
await api.heartbeat("browser_starting", false).catch(() => undefined);
|
|
152
|
+
while (!stopped) {
|
|
153
|
+
if (await shutdownRequested()) { stopped = true; break; }
|
|
154
|
+
if (await paused()) {
|
|
155
|
+
active?.controller.abort();
|
|
156
|
+
await browser.stop();
|
|
157
|
+
const reportBudget = pauseDeadline ? Math.max(0, pauseDeadline - Date.now()) : 5_000;
|
|
158
|
+
await settleWithin(report("paused", false), reportBudget);
|
|
159
|
+
pauseDeadline = 0;
|
|
160
|
+
while (!stopped && await paused()) {
|
|
161
|
+
if (await shutdownRequested()) { stopped = true; break; }
|
|
162
|
+
await sleep(250);
|
|
163
|
+
}
|
|
164
|
+
if (stopped) break;
|
|
165
|
+
await writeStatus({ state: "browser_starting", currentCommandId: null, currentAction: null });
|
|
166
|
+
await api.heartbeat("browser_starting", false).catch(() => undefined);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await browser.start();
|
|
170
|
+
if (stopped) break;
|
|
171
|
+
if (await paused()) continue;
|
|
172
|
+
if (Date.now() >= nextHeartbeat) await report("available", true);
|
|
173
|
+
if (stopped) break;
|
|
174
|
+
if (await paused()) continue;
|
|
175
|
+
const claimed = await api.claim();
|
|
176
|
+
if (!claimed) { await sleep(options.pollMs ?? POLL_MS); continue; }
|
|
177
|
+
const { command, claimToken } = claimed;
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
active = { command, claimToken, controller };
|
|
180
|
+
if (await paused()) controller.abort();
|
|
181
|
+
else await report("working", true, command);
|
|
182
|
+
const deadlineMs = Math.max(1, new Date(command.deadlineAt).getTime() - Date.now());
|
|
183
|
+
const deadline = setTimeout(() => controller.abort(), deadlineMs);
|
|
184
|
+
const pauseWatch = setInterval(() => { void Promise.all([paused(), shutdownRequested()]).then(([isPaused, shutdown]) => { if (shutdown) stopped = true; if (isPaused || shutdown) controller.abort(); }); }, 250);
|
|
185
|
+
const leaseWatch = setInterval(() => {
|
|
186
|
+
void api.lease(command, claimToken).then((lease) => { if (lease?.cancelRequested) controller.abort(); }).catch(() => controller.abort());
|
|
187
|
+
}, options.leaseMs ?? LEASE_MS);
|
|
188
|
+
try {
|
|
189
|
+
let status = "succeeded";
|
|
190
|
+
let result;
|
|
191
|
+
let completionError;
|
|
192
|
+
try {
|
|
193
|
+
result = await browser.execute(command.action, command.payload, { signal: controller.signal, timeoutMs: deadlineMs });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const cancelled = controller.signal.aborted;
|
|
196
|
+
status = cancelled ? "cancelled" : "failed";
|
|
197
|
+
completionError = { code: cancelled ? "command_cancelled" : sanitizedCode(error), message: cancelled ? "Command cancelled." : "The browser action failed." };
|
|
198
|
+
}
|
|
199
|
+
const locallyPaused = status === "cancelled" && await paused();
|
|
200
|
+
if (locallyPaused) {
|
|
201
|
+
pauseDeadline = Date.now() + (await shutdownRequested() ? 3_000 : 5_000);
|
|
202
|
+
await browser.stop().catch(() => undefined);
|
|
203
|
+
await writeStatus({ state: "paused", currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
204
|
+
}
|
|
205
|
+
const commandDeadline = new Date(command.deadlineAt).getTime();
|
|
206
|
+
const completionCutoff = locallyPaused ? Math.min(commandDeadline, pauseDeadline) : commandDeadline;
|
|
207
|
+
const acknowledged = await completeWithRetry(api, command, claimToken, status, result, completionError, completionCutoff);
|
|
208
|
+
if (!acknowledged && !locallyPaused) throw Object.assign(new Error("Command completion was not acknowledged."), { code: "completion_unconfirmed" });
|
|
209
|
+
const outcome = acknowledged ? status : "cancellation unconfirmed";
|
|
210
|
+
console.log(`${new Date().toISOString()} command ${command.id} ${command.action} ${outcome}`);
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(deadline);
|
|
213
|
+
clearInterval(pauseWatch);
|
|
214
|
+
clearInterval(leaseWatch);
|
|
215
|
+
active = null;
|
|
216
|
+
}
|
|
217
|
+
if (!await paused()) await report("available", true);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error.status === 401) throw error;
|
|
220
|
+
if (stopped) break;
|
|
221
|
+
if (await paused()) continue;
|
|
222
|
+
await browser.stop().catch(() => undefined);
|
|
223
|
+
await writeStatus({ state: "error", detailCode: sanitizedCode(error), currentCommandId: null, currentAction: null });
|
|
224
|
+
await api.heartbeat("error", false).catch(() => undefined);
|
|
225
|
+
console.error(`${new Date().toISOString()} worker error ${sanitizedCode(error)}`);
|
|
226
|
+
await sleep(5_000);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
clearInterval(localWatch);
|
|
231
|
+
active?.controller.abort();
|
|
232
|
+
await browser.stop().catch(() => undefined);
|
|
233
|
+
await settleWithin(api.heartbeat("offline", false), 5_000);
|
|
234
|
+
await writeStatus({ state: "offline", currentCommandId: null, currentAction: null }).catch(() => undefined);
|
|
235
|
+
await unlink(STOP_FILE).catch(() => undefined);
|
|
236
|
+
await releaseLock();
|
|
237
|
+
process.off("SIGINT", stop);
|
|
238
|
+
process.off("SIGTERM", stop);
|
|
239
|
+
options.signal?.removeEventListener("abort", stop);
|
|
240
|
+
}
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xevy/heny-connect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Run Heny's isolated, permission-bounded desktop browser worker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"heny-connect": "bin/heny-connect.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"windows",
|
|
11
13
|
"README.md"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
14
16
|
"test": "node --test test/*.node-test.mjs"
|
|
15
17
|
},
|
|
16
18
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
19
|
+
"node": ">=22"
|
|
18
20
|
},
|
|
19
21
|
"publishConfig": {
|
|
20
22
|
"access": "public"
|