Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

Expand Down Expand Up @@ -34,8 +35,8 @@ describe("tailscale endpoint provider", () => {
const dnsName = yield* parseTailscaleMagicDnsName(
`{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
);
assert.equal(dnsName, "desktop.tail.ts.net");
assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
assert.isTrue(malformed._tag === "Failure");
}),
Expand Down
12 changes: 6 additions & 6 deletions apps/desktop/src/backend/tailscaleEndpointProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,17 @@ function resolveTailscaleIpAdvertisedEndpoints(input: {
const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
"resolveTailscaleMagicDnsAdvertisedEndpoint",
)(function* (input: {
readonly dnsName: string | null;
readonly dnsName: Option.Option<string>;
readonly serveEnabled: boolean;
readonly servePort?: number;
readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
if (!input.dnsName) {
if (Option.isNone(input.dnsName)) {
return Option.none();
}

const httpBaseUrl = buildTailscaleHttpsBaseUrl({
magicDnsName: input.dnsName,
magicDnsName: input.dnsName.value,
...(input.servePort === undefined ? {} : { servePort: input.servePort }),
});
const probe =
Expand Down Expand Up @@ -116,13 +116,13 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
input.statusJson === undefined
? yield* readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.catch(() => Effect.succeed(null)),
Effect.catch(() => Effect.succeed(Option.none())),
)
: input.statusJson
? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.catch(() => Effect.succeed(Option.none())),
)
: null;
: Option.none();
const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
dnsName,
serveEnabled: input.serveEnabled === true,
Expand Down
59 changes: 50 additions & 9 deletions packages/tailscale/src/tailscale.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { assert, describe, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/unstable/process";

import {
Expand All @@ -13,6 +17,7 @@ import {
parseTailscaleMagicDnsName,
parseTailscaleStatus,
readTailscaleStatus,
TAILSCALE_STATUS_TIMEOUT,
} from "./tailscale.ts";

const encoder = new TextEncoder();
Expand All @@ -35,22 +40,37 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number })
});
}

function mockHangingHandle() {
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.never,
isRunning: Effect.succeed(true),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
});
}

function mockSpawnerLayer(
handler: (
command: string,
args: ReadonlyArray<string>,
) => { stdout?: string; stderr?: string; code?: number },
) {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
return Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
spawn: (command) => {
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
};
return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
}),
);
},
});
}

describe("tailscale", () => {
Expand All @@ -66,16 +86,16 @@ describe("tailscale", () => {
it.effect("parses MagicDNS names from tailscale status", () =>
Effect.gen(function* () {
const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson);
assert.equal(dnsName, "desktop.tail.ts.net");
assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
}),
);

it.effect("parses status facts", () =>
Effect.gen(function* () {
const status = yield* parseTailscaleStatus(tailscaleStatusJson);
assert.deepEqual(status, {
magicDnsName: "desktop.tail.ts.net",
magicDnsName: Option.some("desktop.tail.ts.net"),
tailnetIpv4Addresses: ["100.100.100.100"],
});
}),
Expand Down Expand Up @@ -106,12 +126,33 @@ describe("tailscale", () => {
return Effect.gen(function* () {
const status = yield* readTailscaleStatus.pipe(Effect.provide(layer));
assert.deepEqual(status, {
magicDnsName: "desktop.tail.ts.net",
magicDnsName: Option.some("desktop.tail.ts.net"),
tailnetIpv4Addresses: ["100.90.1.2"],
});
});
});

it.effect("times out tailscale status with TestClock", () => {
const layer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
spawn: () => Effect.succeed(mockHangingHandle()),
});

return Effect.gen(function* () {
const fiber = yield* Effect.forkChild(
readTailscaleStatus.pipe(Effect.provide(layer), Effect.result),
);
yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.sum(TAILSCALE_STATUS_TIMEOUT, Duration.millis(1)));
const result = yield* Fiber.join(fiber);

assert.equal(result._tag, "Failure");
if (result._tag === "Failure") {
assert.equal(result.failure._tag, "TailscaleCommandError");
assert.equal(result.failure.message, "Tailscale status timed out.");
}
}).pipe(Effect.provide(TestClock.layer()));
});

it.effect("configures tailscale serve through the process spawner service", () => {
const layer = mockSpawnerLayer((command, args) => {
assert.equal(command, "tailscale");
Expand Down
79 changes: 44 additions & 35 deletions packages/tailscale/src/tailscale.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as Data from "effect/Data";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
Expand All @@ -7,24 +7,33 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;
export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;
export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;
export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500);
export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);

export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{
readonly command: readonly string[];
readonly message: string;
readonly exitCode: number | null;
readonly stderr: string;
}> {}
export class TailscaleCommandError extends Schema.TaggedErrorClass<TailscaleCommandError>()(
"TailscaleCommandError",
{
command: Schema.Array(Schema.String),
message: Schema.String,
exitCode: Schema.Union([Schema.Number, Schema.Null]),
stderr: Schema.String,
},
) {}

export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{
readonly cause: unknown;
}> {}
export class TailscaleStatusParseError extends Schema.TaggedErrorClass<TailscaleStatusParseError>()(
"TailscaleStatusParseError",
{
cause: Schema.Defect,
},
) {}

export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{
readonly reason: string;
}> {}
export class TailscaleUnavailableError extends Schema.TaggedErrorClass<TailscaleUnavailableError>()(
"TailscaleUnavailableError",
{
reason: Schema.String,
},
) {}

const TailscaleStatusSelf = Schema.Struct({
DNSName: Schema.optional(Schema.Unknown),
Expand All @@ -39,7 +48,7 @@ export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type;
export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;

export interface TailscaleStatus {
readonly magicDnsName: string | null;
readonly magicDnsName: Option.Option<string>;
readonly tailnetIpv4Addresses: readonly string[];
}

Expand Down Expand Up @@ -69,19 +78,19 @@ const tailscaleCommandError = (

const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));

function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {
function normalizeMagicDnsName(status: TailscaleStatusJson): Option.Option<string> {
const dnsName = status.Self?.DNSName;
if (typeof dnsName !== "string") {
return null;
return Option.none();
}

const normalized = dnsName.trim().replace(/\.$/u, "");
return normalized.length > 0 ? normalized : null;
return normalized.length > 0 ? Option.some(normalized) : Option.none();
}

export const parseTailscaleMagicDnsName = (
rawStatusJson: string,
): Effect.Effect<string | null, TailscaleStatusParseError> =>
): Effect.Effect<Option.Option<string>, TailscaleStatusParseError> =>
decodeTailscaleStatusJson(rawStatusJson).pipe(
Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
Effect.map(normalizeMagicDnsName),
Expand Down Expand Up @@ -177,7 +186,7 @@ export const readTailscaleStatus: Effect.Effect<
return yield* parseTailscaleStatus(stdout);
}).pipe(
Effect.scoped,
Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS),
Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT),
Effect.flatMap((result) =>
Option.match(result, {
onNone: () =>
Expand Down Expand Up @@ -209,7 +218,7 @@ const runTailscaleCommand = (
readonly runMessage: string;
readonly exitMessage: (exitCode: number) => string;
readonly timeoutMessage: string;
readonly timeoutMs: number;
readonly timeout: Duration.Input;
},
): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -246,7 +255,7 @@ const runTailscaleCommand = (
}
}).pipe(
Effect.scoped,
Effect.timeoutOption(input.timeoutMs),
Effect.timeoutOption(input.timeout),
Effect.flatMap((result) =>
Option.match(result, {
onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
Expand All @@ -268,7 +277,7 @@ export const ensureTailscaleServe = (input: {
runMessage: "Failed to run tailscale serve.",
exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
timeoutMessage: "Tailscale serve timed out.",
timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
timeout: TAILSCALE_SERVE_TIMEOUT,
});
};

Expand All @@ -284,21 +293,21 @@ export const disableTailscaleServe = (
runMessage: "Failed to run tailscale serve off.",
exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,
timeoutMessage: "Tailscale serve off timed out.",
timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
timeout: TAILSCALE_SERVE_TIMEOUT,
});
});

export const probeTailscaleHttpsEndpoint = (input: {
readonly baseUrl: string;
readonly timeoutMs?: number;
readonly timeout?: Duration.Input;
}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
const response = yield* Effect.gen(function* () {
const url = new URL("/.well-known/t3/environment", input.baseUrl);
const request = HttpClientRequest.get(url.toString());
return yield* client.execute(request);
}).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS));
}).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));

return Option.match(response, {
onNone: () => false,
Expand All @@ -311,17 +320,17 @@ export const resolveTailscaleHttpsBaseUrl = (
readonly servePort?: number;
} = {},
): Effect.Effect<
string | null,
Option.Option<string>,
TailscaleCommandError | TailscaleStatusParseError,
ChildProcessSpawner.ChildProcessSpawner
> =>
readTailscaleStatus.pipe(
Effect.map((status) =>
status.magicDnsName
? buildTailscaleHttpsBaseUrl({
magicDnsName: status.magicDnsName,
...(input.servePort === undefined ? {} : { servePort: input.servePort }),
})
: null,
Option.map(status.magicDnsName, (magicDnsName) =>
buildTailscaleHttpsBaseUrl({
magicDnsName,
...(input.servePort === undefined ? {} : { servePort: input.servePort }),
}),
),
),
);
Loading