376 lines · 13.0 KB · TypeScript
RawDownload
/** * 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<TailscaleStderrDiagnostic, string | undefined> = {  "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>()(  "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>()(  "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>()(  "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>()(  "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);});