History

Make Tailscale integration more idiomatic Effect

Cursor Agent committed 5 months ago

Commit 87d37d61 parent b3e8c034 files · +100 −51Browse files

Unified · Split

4 files
  1. apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +3 −2
  2. apps/desktop/src/backend/tailscaleEndpointProvider.ts +6 −6
  3. packages/tailscale/src/tailscale.test.ts +48 −9
  4. packages/tailscale/src/tailscale.ts +43 −34
apps/desktop/src/backend/tailscaleEndpointProvider.test.ts+3−2
@@ -1,6 +1,7 @@
11 import { assert, describe, it } from "@effect/vitest";
22 import * as Effect from "effect/Effect";
33 import * as Layer from "effect/Layer";
4+import * as Option from "effect/Option";
45 import { HttpClient } from "effect/unstable/http";
56 import { ChildProcessSpawner } from "effect/unstable/process";
67
@@ -34,8 +35,8 @@ describe("tailscale endpoint provider", () => { · 27 unmodified lines
3435 const dnsName = yield* parseTailscaleMagicDnsName(
3536 `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
3637 );
37− assert.equal(dnsName, "desktop.tail.ts.net");
38− assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
38+ assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
39+ assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
3940 const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
4041 assert.isTrue(malformed._tag === "Failure");
4142 }),
apps/desktop/src/backend/tailscaleEndpointProvider.ts+6−6
@@ -61,17 +61,17 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { · 60 unmodified lines
6161 const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
6262 "resolveTailscaleMagicDnsAdvertisedEndpoint",
6363 )(function* (input: {
64− readonly dnsName: string | null;
64+ readonly dnsName: Option.Option<string>;
6565 readonly serveEnabled: boolean;
6666 readonly servePort?: number;
6767 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
6868 }): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
69− if (!input.dnsName) {
69+ if (Option.isNone(input.dnsName)) {
7070 return Option.none();
7171 }
7272
7373 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
74− magicDnsName: input.dnsName,
74+ magicDnsName: input.dnsName.value,
7575 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
7676 });
7777 const probe =
@@ -116,13 +116,13 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd · 38 unmodified lines
116116 input.statusJson === undefined
117117 ? yield* readTailscaleStatus.pipe(
118118 Effect.map((status) => status.magicDnsName),
119− Effect.catch(() => Effect.succeed(null)),
119+ Effect.catch(() => Effect.succeed(Option.none())),
120120 )
121121 : input.statusJson
122122 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123− Effect.catch(() => Effect.succeed(null)),
123+ Effect.catch(() => Effect.succeed(Option.none())),
124124 )
125− : null;
125+ : Option.none();
126126 const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
127127 dnsName,
128128 serveEnabled: input.serveEnabled === true,
packages/tailscale/src/tailscale.test.ts+48−9
@@ -1,8 +1,12 @@
11 import { assert, describe, it } from "@effect/vitest";
2+import * as Duration from "effect/Duration";
23 import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
35 import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
47 import * as Sink from "effect/Sink";
58 import * as Stream from "effect/Stream";
9+import * as TestClock from "effect/testing/TestClock";
610 import { ChildProcessSpawner } from "effect/unstable/process";
711
812 import {
@@ -13,6 +17,7 @@ import { · 4 unmodified lines
1317 parseTailscaleMagicDnsName,
1418 parseTailscaleStatus,
1519 readTailscaleStatus,
20+ TAILSCALE_STATUS_TIMEOUT,
1621 } from "./tailscale.ts";
1722
1823 const encoder = new TextEncoder();
@@ -35,22 +40,37 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) · 16 unmodified lines
3540 });
3641 }
3742
43+function mockHangingHandle() {
44+ return ChildProcessSpawner.makeHandle({
45+ pid: ChildProcessSpawner.ProcessId(1),
46+ exitCode: Effect.never,
47+ isRunning: Effect.succeed(true),
48+ kill: () => Effect.void,
49+ unref: Effect.succeed(Effect.void),
50+ stdin: Sink.drain,
51+ stdout: Stream.empty,
52+ stderr: Stream.empty,
53+ all: Stream.empty,
54+ getInputFd: () => Sink.drain,
55+ getOutputFd: () => Stream.empty,
56+ });
57+}
58+
3859 function mockSpawnerLayer(
3960 handler: (
4061 command: string,
4162 args: ReadonlyArray<string>,
4263 ) => { stdout?: string; stderr?: string; code?: number },
4364 ) {
44− return Layer.succeed(
45− ChildProcessSpawner.ChildProcessSpawner,
46− ChildProcessSpawner.make((command) => {
65+ return Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
66+ spawn: (command) => {
4767 const childProcess = command as unknown as {
4868 readonly command: string;
4969 readonly args: ReadonlyArray<string>;
5070 };
5171 return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
52− }),
53− );
72+ },
73+ });
5474 }
5575
5676 describe("tailscale", () => {
@@ -66,8 +86,8 @@ describe("tailscale", () => { · 9 unmodified lines
6686 it.effect("parses MagicDNS names from tailscale status", () =>
6787 Effect.gen(function* () {
6888 const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson);
69− assert.equal(dnsName, "desktop.tail.ts.net");
70− assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
89+ assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
90+ assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
7191 }),
7292 );
7393
@@ -75,7 +95,7 @@ describe("tailscale", () => { · 1 unmodified line
7595 Effect.gen(function* () {
7696 const status = yield* parseTailscaleStatus(tailscaleStatusJson);
7797 assert.deepEqual(status, {
78− magicDnsName: "desktop.tail.ts.net",
98+ magicDnsName: Option.some("desktop.tail.ts.net"),
7999 tailnetIpv4Addresses: ["100.100.100.100"],
80100 });
81101 }),
@@ -106,12 +126,31 @@ describe("tailscale", () => { · 24 unmodified lines
106126 return Effect.gen(function* () {
107127 const status = yield* readTailscaleStatus.pipe(Effect.provide(layer));
108128 assert.deepEqual(status, {
109− magicDnsName: "desktop.tail.ts.net",
129+ magicDnsName: Option.some("desktop.tail.ts.net"),
110130 tailnetIpv4Addresses: ["100.90.1.2"],
111131 });
112132 });
113133 });
114134
135+ it.effect("times out tailscale status with TestClock", () => {
136+ const layer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
137+ spawn: () => Effect.succeed(mockHangingHandle()),
138+ });
139+
140+ return Effect.gen(function* () {
141+ const fiber = yield* readTailscaleStatus.pipe(Effect.provide(layer), Effect.result, Effect.fork);
142+ yield* Effect.yieldNow;
143+ yield* TestClock.adjust(Duration.sum(TAILSCALE_STATUS_TIMEOUT, Duration.millis(1)));
144+ const result = yield* Fiber.join(fiber);
145+
146+ assert.equal(result._tag, "Failure");
147+ if (result._tag === "Failure") {
148+ assert.equal(result.failure._tag, "TailscaleCommandError");
149+ assert.equal(result.failure.message, "Tailscale status timed out.");
150+ }
151+ }).pipe(Effect.provide(TestClock.layer()));
152+ });
153+
115154 it.effect("configures tailscale serve through the process spawner service", () => {
116155 const layer = mockSpawnerLayer((command, args) => {
117156 assert.equal(command, "tailscale");
packages/tailscale/src/tailscale.ts+43−34
@@ -1,4 +1,4 @@
1−import * as Data from "effect/Data";
1+import * as Duration from "effect/Duration";
22 import * as Effect from "effect/Effect";
33 import * as Option from "effect/Option";
44 import * as Schema from "effect/Schema";
@@ -7,24 +7,33 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 2 unmodified lines
77 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
88
99 export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
10−export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;
11−export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;
12−export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;
10+export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500);
11+export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
12+export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
1313
14−export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{
15− readonly command: readonly string[];
16− readonly message: string;
17− readonly exitCode: number | null;
18− readonly stderr: string;
19−}> {}
14+export class TailscaleCommandError extends Schema.TaggedErrorClass<TailscaleCommandError>()(
15+ "TailscaleCommandError",
16+ {
17+ command: Schema.Array(Schema.String),
18+ message: Schema.String,
19+ exitCode: Schema.Union([Schema.Number, Schema.Null]),
20+ stderr: Schema.String,
21+ },
22+) {}
2023
21−export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{
22− readonly cause: unknown;
23−}> {}
24+export class TailscaleStatusParseError extends Schema.TaggedErrorClass<TailscaleStatusParseError>()(
25+ "TailscaleStatusParseError",
26+ {
27+ cause: Schema.Defect,
28+ },
29+) {}
2430
25−export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{
26− readonly reason: string;
27−}> {}
31+export class TailscaleUnavailableError extends Schema.TaggedErrorClass<TailscaleUnavailableError>()(
32+ "TailscaleUnavailableError",
33+ {
34+ reason: Schema.String,
35+ },
36+) {}
2837
2938 const TailscaleStatusSelf = Schema.Struct({
3039 DNSName: Schema.optional(Schema.Unknown),
@@ -39,7 +48,7 @@ export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; · 8 unmodified lines
3948 export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;
4049
4150 export interface TailscaleStatus {
42− readonly magicDnsName: string | null;
51+ readonly magicDnsName: Option.Option<string>;
4352 readonly tailnetIpv4Addresses: readonly string[];
4453 }
4554
@@ -69,19 +78,19 @@ const tailscaleCommandError = ( · 23 unmodified lines
6978
7079 const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));
7180
72−function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {
81+function normalizeMagicDnsName(status: TailscaleStatusJson): Option.Option<string> {
7382 const dnsName = status.Self?.DNSName;
7483 if (typeof dnsName !== "string") {
75− return null;
84+ return Option.none();
7685 }
7786
7887 const normalized = dnsName.trim().replace(/\.$/u, "");
79− return normalized.length > 0 ? normalized : null;
88+ return normalized.length > 0 ? Option.some(normalized) : Option.none();
8089 }
8190
8291 export const parseTailscaleMagicDnsName = (
8392 rawStatusJson: string,
84−): Effect.Effect<string | null, TailscaleStatusParseError> =>
93+): Effect.Effect<Option.Option<string>, TailscaleStatusParseError> =>
8594 decodeTailscaleStatusJson(rawStatusJson).pipe(
8695 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
8796 Effect.map(normalizeMagicDnsName),
@@ -177,7 +186,7 @@ export const readTailscaleStatus: Effect.Effect< · 89 unmodified lines
177186 return yield* parseTailscaleStatus(stdout);
178187 }).pipe(
179188 Effect.scoped,
180− Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS),
189+ Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT),
181190 Effect.flatMap((result) =>
182191 Option.match(result, {
183192 onNone: () =>
@@ -209,7 +218,7 @@ const runTailscaleCommand = ( · 25 unmodified lines
209218 readonly runMessage: string;
210219 readonly exitMessage: (exitCode: number) => string;
211220 readonly timeoutMessage: string;
212− readonly timeoutMs: number;
221+ readonly timeout: Duration.Input;
213222 },
214223 ): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
215224 Effect.gen(function* () {
@@ -246,7 +255,7 @@ const runTailscaleCommand = ( · 30 unmodified lines
246255 }
247256 }).pipe(
248257 Effect.scoped,
249− Effect.timeoutOption(input.timeoutMs),
258+ Effect.timeoutOption(input.timeout),
250259 Effect.flatMap((result) =>
251260 Option.match(result, {
252261 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
@@ -268,7 +277,7 @@ export const ensureTailscaleServe = (input: { · 15 unmodified lines
268277 runMessage: "Failed to run tailscale serve.",
269278 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
270279 timeoutMessage: "Tailscale serve timed out.",
271− timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
280+ timeout: TAILSCALE_SERVE_TIMEOUT,
272281 });
273282 };
274283
@@ -284,13 +293,13 @@ export const disableTailscaleServe = ( · 9 unmodified lines
284293 runMessage: "Failed to run tailscale serve off.",
285294 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,
286295 timeoutMessage: "Tailscale serve off timed out.",
287− timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
296+ timeout: TAILSCALE_SERVE_TIMEOUT,
288297 });
289298 });
290299
291300 export const probeTailscaleHttpsEndpoint = (input: {
292301 readonly baseUrl: string;
293− readonly timeoutMs?: number;
302+ readonly timeout?: Duration.Input;
294303 }): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
295304 Effect.gen(function* () {
296305 const client = yield* HttpClient.HttpClient;
@@ -298,7 +307,7 @@ export const probeTailscaleHttpsEndpoint = (input: { · 1 unmodified line
298307 const url = new URL("/.well-known/t3/environment", input.baseUrl);
299308 const request = HttpClientRequest.get(url.toString());
300309 return yield* client.execute(request);
301− }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS));
310+ }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));
302311
303312 return Option.match(response, {
304313 onNone: () => false,
@@ -311,17 +320,17 @@ export const resolveTailscaleHttpsBaseUrl = ( · 6 unmodified lines
311320 readonly servePort?: number;
312321 } = {},
313322 ): Effect.Effect<
314− string | null,
323+ Option.Option<string>,
315324 TailscaleCommandError | TailscaleStatusParseError,
316325 ChildProcessSpawner.ChildProcessSpawner
317326 > =>
318327 readTailscaleStatus.pipe(
319328 Effect.map((status) =>
320− status.magicDnsName
321− ? buildTailscaleHttpsBaseUrl({
322− magicDnsName: status.magicDnsName,
329+ Option.map(status.magicDnsName, (magicDnsName) =>
330+ buildTailscaleHttpsBaseUrl({
331+ magicDnsName,
323332 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
324− })
325− : null,
333+ }),
334+ ),
326335 ),
327336 );