1/**
2 * RemoteOpenTargets - resolves the SSH hostnames this environment advertises
3 * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`).
4 *
5 * The server can only check itself: sshd listening locally, tailscaled
6 * reporting a MagicDNS name and whether Tailscale SSH is on, and the machine
7 * hostname for mDNS. Whether a given name resolves from the viewer's machine
8 * is inherently client-side.
9 * Targets are ordered most-reachable first (tailnet name works from anywhere
10 * on the tailnet; `<hostname>.local` only on the same LAN).
11 */
12import { type RemoteOpenTarget } from "@t3tools/contracts";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14import * as NetService from "@t3tools/shared/Net";
15import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
20
21const SSH_PORT = 22;
22
23export class RemoteOpenTargets extends Context.Service<
24 RemoteOpenTargets,
25 {
26 readonly resolveTargets: () => Effect.Effect<ReadonlyArray<RemoteOpenTarget>>;
27 }
28>()("t3/environment/RemoteOpenTargets") {}
29
30/** @public Service construction is part of the canonical Effect module API. */
31export const make = Effect.gen(function* () {
32 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
33 const net = yield* NetService.NetService;
34
35 const resolveTargets = Effect.gen(function* () {
36 // Check both loopback families: sshd can be bound IPv6-only.
37 const sshdListening = yield* Effect.zipWith(
38 net.hasListenerOnHost(SSH_PORT, "127.0.0.1"),
39 net.hasListenerOnHost(SSH_PORT, "::1"),
40 (ipv4, ipv6) => ipv4 || ipv6,
41 );
42
43 // Tailscale absent or down is the common case, not an error.
44 const magicDnsName = yield* readTailscaleStatus.pipe(
45 Effect.map((status) => status.magicDnsName),
46 Effect.orElseSucceed(() => null),
47 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
48 );
49
50 const targets: Array<RemoteOpenTarget> = [];
51
52 if (!sshdListening) {
53 // Tailscale SSH answers on the tailnet name with no local sshd, so only
54 // that name can work. A running tailscaled alone is not an SSH route.
55 // With neither, advertise nothing so clients render a clear "no SSH
56 // route" state instead of links that hang.
57 if (
58 magicDnsName !== null &&
59 (yield* readTailscaleSshEnabled.pipe(
60 Effect.orElseSucceed(() => false),
61 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
62 ))
63 ) {
64 targets.push({ kind: "tailscale", host: magicDnsName });
65 }
66 return targets;
67 }
68
69 if (magicDnsName !== null) {
70 targets.push({ kind: "tailscale", host: magicDnsName });
71 }
72
73 // os.hostname() may already be an FQDN (macOS often reports
74 // "Name.local"); mDNS names are always `<first-label>.local`.
75 const hostname = yield* HostProcess.Hostname;
76 const shortHostname = hostname.split(".")[0]?.trim();
77 if (shortHostname !== undefined && shortHostname.length > 0) {
78 targets.push({ kind: "mdns", host: `${shortHostname}.local` });
79 }
80
81 return targets;
82 });
83
84 return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets });
85});
86
87export const layer = Layer.effect(RemoteOpenTargets, make);