mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-09 11:51:15 +02:00
224 lines
8.2 KiB
TypeScript
224 lines
8.2 KiB
TypeScript
/**
|
|
* 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 Schema from "effect/Schema";
|
|
import type { ChildProcessSpawner } from "effect/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.TaggedError<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.TaggedError<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.TaggedError<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 type DevShareError =
|
|
| TailscaleUnavailableError
|
|
| TailnetNameMissingError
|
|
| DevServeFailedError;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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 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 } : {}),
|
|
});
|
|
}
|
|
|
|
// Proxy to the hostname Vite binds rather than the package default of
|
|
// 127.0.0.1. Vite listens on `localhost`, which Node 17+ resolves to `::1`
|
|
// first, so it only binds the IPv6 loopback and a 127.0.0.1 target has
|
|
// nothing behind it (tailscale answers 502). Passing `localhost` lets the
|
|
// tailscale proxy resolve it the same way Node did. Not a literal `[::1]`:
|
|
// tailscale rejects that form.
|
|
yield* ensureTailscaleServe({
|
|
localPort: input.webPort,
|
|
servePort: input.webPort,
|
|
localHost: "localhost",
|
|
}).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: status.magicDnsName,
|
|
servePort: input.webPort,
|
|
}),
|
|
host: status.magicDnsName,
|
|
} satisfies DevShareResult;
|
|
});
|