src/cdp/endpoint.ts (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 |
/**
* Which browser this harness should drive.
*
* Upstream only ever drives a browser on localhost. This fork adds a remote
* target so pi can drive a shared Chrome on another host (see FORK.md).
*
* Resolution is pure — no I/O — so the status command can report the configured
* intent without touching the network, and the daemon can resolve the same way
* in its own process.
*/
/** Default remote CDP endpoint: castle's Chrome, exposed on the wire network. */
export const DEFAULT_REMOTE_CDP = "10.88.0.25:9223";
/** Values of BU_CDP_HTTP that mean "ignore the default, use this machine". */
const LOCAL_ALIASES = new Set(["local", "localhost", "off", "none", "0", "no"]);
/**
* Where a target came from. "ws" is upstream's BU_CDP_WS, "env" is BU_CDP_HTTP,
* "stored" is /browser-target, "default" is this fork's built-in. Only
* "default" is allowed to fall back.
*/
export type CdpTargetSource = "ws" | "env" | "stored" | "default";
export type CdpTarget =
| {
readonly kind: "remote";
readonly host: string;
readonly port: number;
readonly source: CdpTargetSource;
}
| { readonly kind: "local"; readonly source: Exclude<CdpTargetSource, "default" | "ws"> }
| { readonly kind: "invalid"; readonly raw: string; readonly reason: string; readonly source: CdpTargetSource };
/**
* Precedence: BU_CDP_WS, then BU_CDP_HTTP, then the /browser-target choice,
* then the built-in default.
*
* BU_CDP_WS -> upstream's pinned WebSocket URL; discoverEndpoint() returns
* it before any of the rest is consulted, so it is reported
* here only so status, /browser-target and the prompt agree
* with what the daemon actually dials
*
* "host:port" -> that remote, and a failure to reach it is fatal
* local|off|… -> upstream behaviour, this machine's own browser
* neither set -> DEFAULT_REMOTE_CDP, falling back to local when unreachable
*
* The environment wins over the stored choice so that a one-off
* `BU_CDP_HTTP=… pi` is never silently overridden by a setting made days ago.
*
* The fallback asymmetry is deliberate. An explicit endpoint — from either
* source — is an instruction, so silently driving a different browser instead
* would be wrong: it would automate the operator's own signed-in Chrome rather
* than the shared one. The built-in default is only a preference, so a laptop
* away from the wire network still gets the local browser the extension was
* originally written for.
*
* `stored` is passed in rather than read here so this stays pure and
* synchronous: /browser-status can describe the target without I/O, and the
* daemon can resolve identically in its own process.
*/
export const resolveCdpTarget = (
env: NodeJS.ProcessEnv = process.env,
stored: string | null = null,
): CdpTarget => {
// FORK: mirror upstream's endpointFromEnv() in discovery.ts, which wins.
const pinnedWs = env["BU_CDP_WS"]?.trim();
if (pinnedWs) {
const parsed = parseWsUrl(pinnedWs);
if (!parsed) {
return { kind: "invalid", raw: redactWsUrl(pinnedWs), source: "ws", reason: "expected a ws:// or wss:// URL" };
}
return { kind: "remote", host: parsed.host, port: parsed.port, source: "ws" };
}
const fromEnv = env["BU_CDP_HTTP"]?.trim();
const fromStore = stored?.trim();
const raw = fromEnv !== undefined && fromEnv !== "" ? fromEnv : fromStore;
const source: CdpTargetSource = fromEnv !== undefined && fromEnv !== "" ? "env" : "stored";
if (raw === undefined || raw === "") {
const parsed = parseHostPort(DEFAULT_REMOTE_CDP);
// Unreachable in practice: the constant above is validated by its own test.
if (!parsed) return { kind: "local", source: "env" };
return { kind: "remote", host: parsed.host, port: parsed.port, source: "default" };
}
if (LOCAL_ALIASES.has(raw.toLowerCase())) return { kind: "local", source };
const parsed = parseHostPort(raw);
if (!parsed) {
return {
kind: "invalid",
raw,
source,
reason: `expected "host:port" or one of ${[...LOCAL_ALIASES].join(", ")}`,
};
}
return { kind: "remote", host: parsed.host, port: parsed.port, source };
};
/** The vocabulary /browser-target accepts, for help text and validation. */
export const localAliases = (): ReadonlyArray<string> => [...LOCAL_ALIASES];
/** True when `value` is something resolveCdpTarget can make sense of. */
export const isValidTargetValue = (value: string): boolean => {
const v = value.trim();
if (v === "") return false;
return LOCAL_ALIASES.has(v.toLowerCase()) || parseHostPort(v) !== null;
};
const parseHostPort = (value: string): { host: string; port: number } | null => {
const sep = value.lastIndexOf(":");
if (sep === -1) return null;
const host = value.slice(0, sep).trim();
const port = Number(value.slice(sep + 1).trim());
if (!host) return null;
if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null;
return { host, port };
};
/**
* host:port of a BU_CDP_WS URL. Only that much is ever shown: the path carries
* the browser's id, and a proxied endpoint may carry a token or credentials.
*/
const parseWsUrl = (value: string): { host: string; port: number } | null => {
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== "ws:" && url.protocol !== "wss:") return null;
const host = url.hostname.replace(/^\[(.*)\]$/, "$1");
if (!host) return null;
const port = url.port === "" ? (url.protocol === "wss:" ? 443 : 80) : Number(url.port);
return { host, port };
};
/** A BU_CDP_WS value safe to print: host:port when parseable, otherwise a placeholder. */
export const redactWsUrl = (value: string): string => {
const parsed = parseWsUrl(value.trim());
return parsed ? `${parsed.host}:${parsed.port}` : "<unparseable URL>";
};
const sourceLabel = (source: CdpTargetSource): string =>
source === "ws"
? "BU_CDP_WS, pinned WebSocket"
: source === "env"
? "BU_CDP_HTTP"
: source === "stored"
? "/browser-target"
: "built-in default";
/** One-line description for /browser-status and error messages. */
export const describeCdpTarget = (target: CdpTarget): string => {
switch (target.kind) {
case "remote":
return `${target.host}:${target.port} (${sourceLabel(target.source)})`;
case "local":
return `this machine's own browser (${sourceLabel(target.source)})`;
case "invalid":
return `invalid target "${target.raw}" from ${sourceLabel(target.source)} — ${target.reason}`;
}
};
|