1/**
2 * Shares a running dev server on the local tailnet via `tailscale serve`, so it
3 * can be opened from a phone, another laptop, or by whoever is reviewing the
4 * work.
5 *
6 * Thin wrapper over `@t3tools/tailscale` (the same client the server's own
7 * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a
8 * stale mapping left by a killed run, and refusing to serve over routes it
9 * could not remove.
10 *
11 * Because browser dev is single-origin (Vite proxies the backend — see
12 * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering
13 * the web port is enough; the backend needs no mapping of its own.
14 */
15
16import {
17 buildTailscaleHttpsBaseUrl,
18 disableTailscaleServe,
19 ensureTailscaleServe,
20 readTailscaleStatus,
21 type TailscaleCommandError,
22 type TailscaleStderrDiagnostic,
23} from "@t3tools/tailscale";
24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
26import type { ChildProcessSpawner } from "effect/process";
27
28/**
29 * Human-readable gloss for each diagnostic. Deliberately our own words rather
30 * than the CLI's: tailscale prints auth keys and node names into stderr, and
31 * this string is logged.
32 */
33const DIAGNOSTIC_EXPLANATIONS: Record<TailscaleStderrDiagnostic, string | undefined> = {
34 "no-existing-handler": "no mapping existed for that port",
35 "not-logged-in": "this machine is not logged into a tailnet — run `tailscale up`",
36 "permission-denied": "permission denied — `tailscale serve` may need elevated privileges",
37 unknown: undefined,
38};
39
40/**
41 * Our own wording for why a tailscale command failed, derived from the
42 * classified diagnostic. Never the CLI's text — see `stderrDiagnosticOf`.
43 */
44const explainCommandFailure = (error: TailscaleCommandError): string | undefined =>
45 error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined
46 ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why")
47 : undefined;
48
49/**
50 * Three distinct failures, three classes: each has its own caller-visible
51 * message and its own remedy, and `shareDevServer` chooses between them
52 * structurally. A single error with a `reason` discriminator would encode that
53 * distinction twice and put a lookup table in the `message` getter.
54 *
55 * Each wraps a real underlying failure and so keeps it as `cause`; the message
56 * is derived only from the structural fields, never from `cause.message`.
57 */
58export class TailscaleUnavailableError extends Schema.TaggedError<TailscaleUnavailableError>()(
59 "TailscaleUnavailableError",
60 { cause: Schema.Defect() },
61) {
62 override get message(): string {
63 return "could not talk to tailscale";
64 }
65
66 get hint(): string {
67 return "Is Tailscale installed and tailscaled running? Try `tailscale status` — or drop --share and open the printed localhost URL.";
68 }
69}
70
71/** No underlying failure: the status read succeeded and simply had no name. */
72export class TailnetNameMissingError extends Schema.TaggedError<TailnetNameMissingError>()(
73 "TailnetNameMissingError",
74 {},
75) {
76 override get message(): string {
77 return "this machine has no tailnet DNS name";
78 }
79
80 get hint(): string {
81 return "Run `tailscale up` and make sure MagicDNS is enabled.";
82 }
83}
84
85/**
86 * `stage` is a genuine multi-value discriminator: both stages share the same
87 * semantics (a `tailscale serve` invocation failed for this port) and differ
88 * only in which one, which the message states plainly.
89 */
90export class DevServeFailedError extends Schema.TaggedError<DevServeFailedError>()(
91 "DevServeFailedError",
92 {
93 stage: Schema.Literals(["clear-existing", "serve"]),
94 webPort: Schema.Number,
95 explanation: Schema.optional(Schema.String),
96 cause: Schema.optional(Schema.Defect()),
97 },
98) {
99 override get message(): string {
100 const port = String(this.webPort);
101 const base =
102 this.stage === "clear-existing"
103 ? `could not clear the existing mapping for port ${port}. Run \`tailscale serve --https=${port} off\` and retry`
104 : `could not serve port ${port} on the tailnet (it is no longer served; any previous mapping for it was cleared before this attempt)`;
105 return this.explanation ? `${base}: ${this.explanation}` : base;
106 }
107
108 get hint(): undefined {
109 return undefined;
110 }
111}
112
113export type DevShareError =
114 | TailscaleUnavailableError
115 | TailnetNameMissingError
116 | DevServeFailedError;
117
118/**
119 * Removes any mapping for `webPort`, reporting whether the port is now clear.
120 *
121 * Runs uninterruptibly: this is called from a finalizer on the way out of an
122 * interrupted program, and cancelling the cleanup subprocess would leave
123 * exactly the stale mapping it exists to remove.
124 */
125export const unshareDevServer = (
126 webPort: number,
127): Effect.Effect<
128 {
129 readonly cleared: boolean;
130 readonly explanation?: string | undefined;
131 // Kept structured so a caller wrapping this can preserve the real error
132 // chain rather than a flattened string.
133 readonly cause?: TailscaleCommandError | undefined;
134 },
135 never,
136 ChildProcessSpawner.ChildProcessSpawner
137> =>
138 disableTailscaleServe({ servePort: webPort }).pipe(
139 Effect.as({ cleared: true } as const),
140 Effect.catch((error: TailscaleCommandError) =>
141 Effect.succeed(
142 // "Nothing was mapped" leaves the port clear either way.
143 error._tag === "TailscaleCommandExitError" &&
144 error.stderrDiagnostic === "no-existing-handler"
145 ? ({ cleared: true } as const)
146 : ({
147 cleared: false,
148 ...(explainCommandFailure(error) !== undefined
149 ? { explanation: explainCommandFailure(error) }
150 : {}),
151 cause: error,
152 } as const),
153 ),
154 ),
155 Effect.uninterruptible,
156 );
157
158export interface DevShareResult {
159 readonly url: string;
160 readonly host: string;
161}
162
163/**
164 * Publishes `webPort` on the tailnet at the same port number and returns the
165 * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping.
166 */
167export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: {
168 readonly webPort: number;
169}) {
170 const status = yield* readTailscaleStatus.pipe(
171 Effect.mapError((error) => new TailscaleUnavailableError({ cause: error })),
172 );
173 if (status.magicDnsName === null) {
174 return yield* new TailnetNameMissingError();
175 }
176
177 // Clear any mapping left behind by a run that was killed before its finalizer
178 // could fire. Serve config survives both the process and a reboot, and a
179 // stale entry may carry path routes we no longer want — older versions mapped
180 // /ws, /api and friends to a separate backend port, and serving "/" alone
181 // would leave those pointing at a port nothing is listening on.
182 const cleared = yield* unshareDevServer(input.webPort);
183 if (!cleared.cleared) {
184 // Serving over routes we failed to remove would hand out a URL that is
185 // broken in a way the user cannot see: the page loads while /ws and /api
186 // silently resolve to a dead backend. Better to refuse and say why.
187 return yield* new DevServeFailedError({
188 stage: "clear-existing",
189 webPort: input.webPort,
190 ...(cleared.explanation !== undefined ? { explanation: cleared.explanation } : {}),
191 ...(cleared.cause !== undefined ? { cause: cleared.cause } : {}),
192 });
193 }
194
195 // Proxy to the hostname Vite binds rather than the package default of
196 // 127.0.0.1. Vite listens on `localhost`, which Node 17+ resolves to `::1`
197 // first, so it only binds the IPv6 loopback and a 127.0.0.1 target has
198 // nothing behind it (tailscale answers 502). Passing `localhost` lets the
199 // tailscale proxy resolve it the same way Node did. Not a literal `[::1]`:
200 // tailscale rejects that form.
201 yield* ensureTailscaleServe({
202 localPort: input.webPort,
203 servePort: input.webPort,
204 localHost: "localhost",
205 }).pipe(
206 Effect.mapError((error) => {
207 const explanation = explainCommandFailure(error);
208 return new DevServeFailedError({
209 stage: "serve",
210 webPort: input.webPort,
211 ...(explanation !== undefined ? { explanation } : {}),
212 cause: error,
213 });
214 }),
215 );
216
217 return {
218 url: buildTailscaleHttpsBaseUrl({
219 magicDnsName: status.magicDnsName,
220 servePort: input.webPort,
221 }),
222 host: status.magicDnsName,
223 } satisfies DevShareResult;
224});