Skip to content
Source
Sign inSign up
Open History to see the last commit

dev-share.ts

176 lines6.0 KB

1/** Vite proxies the backend, so only the web port needs a share mapping. */
2​
3import {
4 buildTailscaleHttpsBaseUrl,
5 disableTailscaleServe,
6 ensureTailscaleServe,
7 readTailscaleStatus,
8 type TailscaleCommandError,
9 type TailscaleServeHttpsDisabledError,
10 type TailscaleStderrDiagnostic,
11} from "@t3tools/tailscale";
12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
14import type { ChildProcessSpawner } from "effect/process";
15​
16/** Log fixed diagnostics; tailscale stderr can contain auth keys and node names. */
17const DIAGNOSTIC_EXPLANATIONS: Record<TailscaleStderrDiagnostic, string | undefined> = {
18 "no-existing-handler": "no mapping existed for that port",
19 "not-logged-in": "this machine is not logged into a tailnet — run `tailscale up`",
20 "not-running": "Tailscale is not running; start it and try again",
21 "permission-denied": "permission denied — `tailscale serve` may need elevated privileges",
22 "unsupported-target": "this Tailscale install can only proxy to localhost",
23 unknown: undefined,
24};
25​
26const explainCommandFailure = (
27 error: TailscaleCommandError | TailscaleServeHttpsDisabledError,
28): string | undefined =>
29 error._tag === "TailscaleServeHttpsDisabledError"
30 ? "HTTPS is not enabled on this tailnet — enable HTTPS certificates at https://login.tailscale.com/admin/dns"
31 : error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined
32 ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why")
33 : undefined;
34​
35export class TailscaleUnavailableError extends Schema.TaggedError<TailscaleUnavailableError>()(
36 "TailscaleUnavailableError",
37 { cause: Schema.Defect() },
38) {
39 override get message(): string {
40 return "could not talk to tailscale";
41 }
42​
43 get hint(): string {
44 return "Is Tailscale installed and tailscaled running? Try `tailscale status` — or drop --share and open the printed localhost URL.";
45 }
47​
48export class TailnetNameMissingError extends Schema.TaggedError<TailnetNameMissingError>()(
49 "TailnetNameMissingError",
50 {},
51) {
52 override get message(): string {
53 return "this machine has no tailnet DNS name";
54 }
55​
56 get hint(): string {
57 return "Run `tailscale up` and make sure MagicDNS is enabled.";
58 }
60​
61export class DevServeFailedError extends Schema.TaggedError<DevServeFailedError>()(
62 "DevServeFailedError",
63 {
64 stage: Schema.Literals(["clear-existing", "serve"]),
65 webPort: Schema.Number,
66 explanation: Schema.optional(Schema.String),
67 cause: Schema.optional(Schema.Defect()),
68 },
69) {
70 override get message(): string {
71 const port = String(this.webPort);
72 const base =
73 this.stage === "clear-existing"
74 ? `could not clear the existing mapping for port ${port}. Run \`tailscale serve --https=${port} --set-path=/ off\` and retry`
75 : `could not serve port ${port} on the tailnet (it is no longer served; any previous mapping for it was cleared before this attempt)`;
76 return this.explanation ? `${base}: ${this.explanation}` : base;
77 }
78​
79 get hint(): undefined {
80 return undefined;
81 }
83​
84export type DevShareError =
85 | TailscaleUnavailableError
86 | TailnetNameMissingError
87 | DevServeFailedError;
88​
89/** Finalizer cleanup must be uninterruptible or cancellation can leave stale mappings. */
90export const unshareDevServer = (
91 webPort: number,
92): Effect.Effect<
93 {
94 readonly cleared: boolean;
95 readonly explanation?: string | undefined;
96 readonly cause?: TailscaleCommandError | undefined;
97 },
98 never,
99 ChildProcessSpawner.ChildProcessSpawner
100> =>
101 disableTailscaleServe({ servePort: webPort }).pipe(
102 Effect.as({ cleared: true } as const),
103 Effect.catch((error: TailscaleCommandError) =>
104 Effect.succeed(
105 error._tag === "TailscaleCommandExitError" &&
106 error.stderrDiagnostic === "no-existing-handler"
107 ? ({ cleared: true } as const)
108 : ({
109 cleared: false,
110 ...(explainCommandFailure(error) !== undefined
111 ? { explanation: explainCommandFailure(error) }
112 : {}),
113 cause: error,
114 } as const),
115 ),
116 ),
117 Effect.uninterruptible,
118 );
119​
120export interface DevShareResult {
121 readonly url: string;
122 readonly host: string;
124​
125/**
126 * Publishes `webPort` on the tailnet. The tailnet port always matches the web
127 * port so the URL is stable; `localTarget` points it at the backend instead
128 * when the backend serves a built client (`--built`).
129 */
130export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: {
131 readonly webPort: number;
132 readonly localTarget?: { readonly port: number; readonly host: string };
133}) {
134 const status = yield* readTailscaleStatus.pipe(
135 Effect.mapError((error) => new TailscaleUnavailableError({ cause: error })),
136 );
137 if (status.magicDnsName === null) {
138 return yield* new TailnetNameMissingError();
139 }
140​
141 const cleared = yield* unshareDevServer(input.webPort);
142 if (!cleared.cleared) {
143 // Refuse stale routes: the page could load while /ws and /api still point at a dead backend.
144 return yield* new DevServeFailedError({
145 stage: "clear-existing",
146 webPort: input.webPort,
147 ...(cleared.explanation !== undefined ? { explanation: cleared.explanation } : {}),
148 ...(cleared.cause !== undefined ? { cause: cleared.cause } : {}),
149 });
150 }
151​
152 // Vite's localhost can bind only ::1; a 127.0.0.1 proxy target would return 502.
153 yield* ensureTailscaleServe({
154 localPort: input.localTarget?.port ?? input.webPort,
155 servePort: input.webPort,
156 localHost: input.localTarget?.host ?? "localhost",
157 }).pipe(
158 Effect.mapError((error) => {
159 const explanation = explainCommandFailure(error);
160 return new DevServeFailedError({
161 stage: "serve",
162 webPort: input.webPort,
163 ...(explanation !== undefined ? { explanation } : {}),
164 cause: error,
165 });
166 }),
167 );
168​
169 return {
170 url: buildTailscaleHttpsBaseUrl({
171 magicDnsName: status.magicDnsName,
172 servePort: input.webPort,
173 }),
174 host: status.magicDnsName,
175 } satisfies DevShareResult;
176});