aboutsummaryrefslogtreecommitdiff
path: root/src/cdp/endpoint.ts
blob: 96cf2ea455d4dff63b01c200a03d48d682ee39e8 (plain)
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
/**
 * 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. "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 = "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"> }
  | { readonly kind: "invalid"; readonly raw: string; readonly reason: string; readonly source: CdpTargetSource };

/**
 * Precedence: BU_CDP_HTTP, then the /browser-target choice, then the built-in
 * default.
 *
 *   "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 => {
  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 };
};

const sourceLabel = (source: CdpTargetSource): string =>
  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}`;
  }
};