summaryrefslogtreecommitdiff
path: root/src/endpoint.ts
blob: d3f4b0d6b7b3e93f52f575cb510c6f63628f9144 (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
/**
 * Which browser this extension searches through.
 *
 * Deliberately the *same* configuration as pi-browser-harness, so one decision
 * governs both packages and they never drive different browsers in the same
 * session: `BU_CDP_HTTP`, then the `/browser-target` file that harness writes,
 * then the built-in castle default.
 *
 * Resolution is pure — no I/O — so it can be unit-tested and reported without
 * touching the network.
 */

/** Default CDP endpoint: castle's Chrome, exposed on the wg8 overlay. */
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"]);

/** Port a local Chrome is expected to serve CDP on when the target is "local". */
export const LOCAL_CDP_PORT = 9222;

/**
 * Where a target came from. "env" is BU_CDP_HTTP, "stored" is the harness's
 * `/browser-target`, "default" is the built-in.
 */
export type CdpTargetSource = "env" | "stored" | "default";

export type CdpTarget = {
  readonly host: string;
  readonly port: number;
  readonly source: CdpTargetSource;
};

export type CdpTargetResolution =
  | { readonly kind: "ok"; readonly target: CdpTarget }
  | { readonly kind: "invalid"; readonly raw: string; readonly reason: string; readonly source: CdpTargetSource };

/**
 * Precedence: BU_CDP_HTTP, then the stored `/browser-target` choice, then the
 * built-in default. The environment wins so a one-off `BU_CDP_HTTP=… pi` is
 * never silently overridden by a choice made days ago.
 *
 * Unlike pi-browser-harness this never falls back from the default to a local
 * browser on its own. The harness can fall back because it drives a browser the
 * operator is watching; here a silent fallback would mean *searching from the
 * operator's own signed-in Chrome* without saying so. When the configured
 * endpoint is unreachable the caller reports that, and the operator can say
 * `local` explicitly.
 *
 * `stored` is passed in rather than read here so this stays pure and synchronous.
 */
export const resolveCdpTarget = (
  env: NodeJS.ProcessEnv = process.env,
  stored: string | null = null,
): CdpTargetResolution => {
  const fromEnv = env["BU_CDP_HTTP"]?.trim();
  const fromStore = stored?.trim();

  const hasEnv = fromEnv !== undefined && fromEnv !== "";
  const raw = hasEnv ? fromEnv : fromStore;
  const source: CdpTargetSource = hasEnv ? "env" : "stored";

  if (raw === undefined || raw === "") {
    const parsed = parseHostPort(DEFAULT_REMOTE_CDP);
    // Unreachable in practice: the constant above is covered by its own test.
    if (!parsed) {
      return { kind: "invalid", raw: DEFAULT_REMOTE_CDP, source: "default", reason: "built-in default is malformed" };
    }
    return { kind: "ok", target: { ...parsed, source: "default" } };
  }

  if (LOCAL_ALIASES.has(raw.toLowerCase())) {
    return { kind: "ok", target: { host: "127.0.0.1", port: LOCAL_CDP_PORT, source } };
  }

  const parsed = parseHostPort(raw);
  if (!parsed) {
    return {
      kind: "invalid",
      raw,
      source,
      reason: `expected "host:port" or one of ${[...LOCAL_ALIASES].join(", ")}`,
    };
  }
  return { kind: "ok", target: { ...parsed, source } };
};

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 progress messages and errors. */
export const describeCdpTarget = (target: CdpTarget): string =>
  `${target.host}:${target.port} (${sourceLabel(target.source)})`;

/** One-line description of a resolution failure. */
export const describeResolutionFailure = (
  failure: Extract<CdpTargetResolution, { kind: "invalid" }>,
): string => `invalid CDP target "${failure.raw}" from ${sourceLabel(failure.source)} — ${failure.reason}`;