/** * Shares a running dev server on the local tailnet via `tailscale serve`, so it * can be opened from a phone, another laptop, or by whoever is reviewing the * work. * * Thin wrapper over `@t3tools/tailscale` (the same client the server's own * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a * stale mapping left by a killed run, and refusing to serve over routes it * could not remove. * * Because browser dev is single-origin (Vite proxies the backend — see * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering * the web port is enough; the backend needs no mapping of its own. */ import { buildTailscaleHttpsBaseUrl, disableTailscaleServe, ensureTailscaleServe, readTailscaleStatus, type TailscaleCommandError, type TailscaleStderrDiagnostic, } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { ChildProcessSpawner } from "effect/unstable/process"; /** * Human-readable gloss for each diagnostic. Deliberately our own words rather * than the CLI's: tailscale prints auth keys and node names into stderr, and * this string is logged. */ const DIAGNOSTIC_EXPLANATIONS: Record = { "no-existing-handler": "no mapping existed for that port", "not-logged-in": "this machine is not logged into a tailnet — run `tailscale up`", "permission-denied": "permission denied — `tailscale serve` may need elevated privileges", unknown: undefined, }; /** * Our own wording for why a tailscale command failed, derived from the * classified diagnostic. Never the CLI's text — see `stderrDiagnosticOf`. */ const explainCommandFailure = (error: TailscaleCommandError): string | undefined => error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why") : undefined; /** * Three distinct failures, three classes: each has its own caller-visible * message and its own remedy, and `shareDevServer` chooses between them * structurally. A single error with a `reason` discriminator would encode that * distinction twice and put a lookup table in the `message` getter. * * Each wraps a real underlying failure and so keeps it as `cause`; the message * is derived only from the structural fields, never from `cause.message`. */ export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( "TailscaleUnavailableError", { cause: Schema.Defect() }, ) { override get message(): string { return "could not talk to tailscale"; } get hint(): string { return "Is Tailscale installed and tailscaled running? Try `tailscale status` — or drop --share and open the printed localhost URL."; } } /** No underlying failure: the status read succeeded and simply had no name. */ export class TailnetNameMissingError extends Schema.TaggedErrorClass()( "TailnetNameMissingError", {}, ) { override get message(): string { return "this machine has no tailnet DNS name"; } get hint(): string { return "Run `tailscale up` and make sure MagicDNS is enabled."; } } /** * `stage` is a genuine multi-value discriminator: both stages share the same * semantics (a `tailscale serve` invocation failed for this port) and differ * only in which one, which the message states plainly. */ export class DevServeFailedError extends Schema.TaggedErrorClass()( "DevServeFailedError", { stage: Schema.Literals(["clear-existing", "serve"]), webPort: Schema.Number, explanation: Schema.optional(Schema.String), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { const port = String(this.webPort); const base = this.stage === "clear-existing" ? `could not clear the existing mapping for port ${port}. Run \`tailscale serve --https=${port} off\` and retry` : `could not serve port ${port} on the tailnet (it is no longer served; any previous mapping for it was cleared before this attempt)`; return this.explanation ? `${base}: ${this.explanation}` : base; } get hint(): undefined { return undefined; } } export class DevShareLeaseClaimError extends Schema.TaggedErrorClass()( "DevShareLeaseClaimError", { leasePath: Schema.String, cause: Schema.Defect(), restoreCause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { return this.restoreCause === undefined ? `could not claim cleanup ownership at ${this.leasePath}` : `could not claim cleanup ownership at ${this.leasePath} or restore the previous tailnet mapping`; } get hint(): string { return this.restoreCause === undefined ? "Check that the dev data directory is writable." : "Check that the dev data directory is writable, then run the share command again."; } } export const DevShareError = Schema.Union([ TailscaleUnavailableError, TailnetNameMissingError, DevServeFailedError, DevShareLeaseClaimError, ]); export type DevShareError = typeof DevShareError.Type; export const isDevShareError = Schema.is(DevShareError); /** * Removes any mapping for `webPort`, reporting whether the port is now clear. * * Runs uninterruptibly: this is called from a finalizer on the way out of an * interrupted program, and cancelling the cleanup subprocess would leave * exactly the stale mapping it exists to remove. */ export const unshareDevServer = ( webPort: number, ): Effect.Effect< { readonly cleared: boolean; readonly explanation?: string | undefined; // Kept structured so a caller wrapping this can preserve the real error // chain rather than a flattened string. readonly cause?: TailscaleCommandError | undefined; }, never, ChildProcessSpawner.ChildProcessSpawner > => disableTailscaleServe({ servePort: webPort }).pipe( Effect.as({ cleared: true } as const), Effect.catch((error: TailscaleCommandError) => Effect.succeed( // "Nothing was mapped" leaves the port clear either way. error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic === "no-existing-handler" ? ({ cleared: true } as const) : ({ cleared: false, ...(explainCommandFailure(error) !== undefined ? { explanation: explainCommandFailure(error) } : {}), cause: error, } as const), ), ), Effect.uninterruptible, ); export interface DevShareResult { readonly url: string; readonly host: string; } export interface DevShareLease { readonly leasePath: string; readonly ownerId: string; readonly webPort: number; } export const claimDevShareLease = Effect.fn("devShare.claimDevShareLease")(function* ( lease: DevShareLease, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* Effect.scoped( Effect.gen(function* () { const leaseDirectory = path.dirname(lease.leasePath); yield* fileSystem.makeDirectory(leaseDirectory, { recursive: true }); const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ directory: leaseDirectory, prefix: `${path.basename(lease.leasePath)}.`, }); const tempPath = path.join(tempDirectory, "owner.tmp"); yield* fileSystem.writeFileString(tempPath, lease.ownerId); yield* fileSystem.rename(tempPath, lease.leasePath); }), ).pipe( Effect.mapError((cause) => new DevShareLeaseClaimError({ leasePath: lease.leasePath, cause })), ); return lease; }); export type DevShareCleanupResult = | { readonly status: "cleared" | "superseded" | "restored" } | { readonly status: "failed"; readonly explanation: string }; /** * Clears only the mapping this runner owns. If a successor claims the lease * while `tailscale serve off` is in flight, restore the same port mapping so * the old finalizer cannot silently disconnect the new runner. */ export const cleanupOwnedDevShare = Effect.fn("devShare.cleanupOwnedDevShare")(function* ( lease: DevShareLease, ): Effect.fn.Return< DevShareCleanupResult, never, FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner > { const fileSystem = yield* FileSystem.FileSystem; const readOwner = fileSystem .readFileString(lease.leasePath) .pipe(Effect.option, Effect.map(Option.getOrUndefined)); const ownerBefore = yield* readOwner; if (ownerBefore !== lease.ownerId) { return { status: "superseded" }; } const result = yield* unshareDevServer(lease.webPort); if (!result.cleared) { return { status: "failed", explanation: result.explanation ?? `could not remove the tailnet mapping for port ${String(lease.webPort)}`, }; } const ownerAfter = yield* readOwner; if (ownerAfter === lease.ownerId) { return { status: "cleared" }; } return yield* ensureTailscaleServe({ localPort: lease.webPort, servePort: lease.webPort, }).pipe( Effect.as({ status: "restored" } as const), Effect.catch((cause: TailscaleCommandError) => Effect.succeed({ status: "failed", explanation: explainCommandFailure(cause) ?? `a newer runner claimed port ${String(lease.webPort)}, but its mapping could not be restored`, } as const), ), Effect.uninterruptible, ); }); const prepareDevShare = Effect.fn("devShare.prepareDevShare")(function* (input: { readonly webPort: number; }) { const status = yield* readTailscaleStatus.pipe( Effect.mapError((error) => new TailscaleUnavailableError({ cause: error })), ); if (status.magicDnsName === null) { return yield* new TailnetNameMissingError(); } // Clear any mapping left behind by a run that was killed before its finalizer // could fire. Serve config survives both the process and a reboot, and a // stale entry may carry path routes we no longer want — older versions mapped // /ws, /api and friends to a separate backend port, and serving "/" alone // would leave those pointing at a port nothing is listening on. const cleared = yield* unshareDevServer(input.webPort); if (!cleared.cleared) { // Serving over routes we failed to remove would hand out a URL that is // broken in a way the user cannot see: the page loads while /ws and /api // silently resolve to a dead backend. Better to refuse and say why. return yield* new DevServeFailedError({ stage: "clear-existing", webPort: input.webPort, ...(cleared.explanation !== undefined ? { explanation: cleared.explanation } : {}), ...(cleared.cause !== undefined ? { cause: cleared.cause } : {}), }); } return status.magicDnsName; }); const publishDevShare = Effect.fn("devShare.publishDevShare")(function* (input: { readonly host: string; readonly webPort: number; }) { yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort, }).pipe( Effect.mapError((error) => { const explanation = explainCommandFailure(error); return new DevServeFailedError({ stage: "serve", webPort: input.webPort, ...(explanation !== undefined ? { explanation } : {}), cause: error, }); }), ); return { url: buildTailscaleHttpsBaseUrl({ magicDnsName: input.host, servePort: input.webPort, }), host: input.host, } satisfies DevShareResult; }); /** * Publishes `webPort` on the tailnet at the same port number and returns the * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping. */ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: { readonly webPort: number; }) { const host = yield* prepareDevShare(input); return yield* publishDevShare({ ...input, host }); }); /** * Transfers cleanup ownership after the old mapping is known to be gone and * before the new mapping is published. Failures on either side of that * boundary therefore cannot leave a surviving mapping without an owner. */ export const acquireDevShare = Effect.fn("devShare.acquireDevShare")(function* ( lease: DevShareLease, ) { return yield* Effect.gen(function* () { const host = yield* prepareDevShare({ webPort: lease.webPort }); yield* claimDevShareLease(lease).pipe( Effect.catch((error: DevShareLeaseClaimError) => publishDevShare({ host, webPort: lease.webPort }).pipe( Effect.matchEffect({ onFailure: (restoreCause) => Effect.fail( new DevShareLeaseClaimError({ leasePath: lease.leasePath, cause: error.cause, restoreCause, }), ), onSuccess: () => Effect.fail(error), }), ), ), ); return yield* publishDevShare({ host, webPort: lease.webPort }); }).pipe(Effect.uninterruptible); });