| 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 | |
| 16 | import { |
| 17 | buildTailscaleHttpsBaseUrl, |
| 18 | disableTailscaleServe, |
| 19 | ensureTailscaleServe, |
| 20 | readTailscaleStatus, |
| 21 | type TailscaleCommandError, |
| 22 | type TailscaleStderrDiagnostic, |
| 23 | } from "@t3tools/tailscale"; |
| 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as Schema from "effect/Schema"; |
| 26 | import 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 | */ |
| 33 | const 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 | */ |
| 44 | const 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 | */ |
| 58 | export 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. */ |
| 72 | export 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 | */ |
| 90 | export 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 | |
| 113 | export 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 | */ |
| 125 | export 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 | |
| 158 | export 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 | */ |
| 167 | export 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 | }); |