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 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
4import { HttpClient } from "effect/unstable/http";5import { HttpClient } from "effect/unstable/http";
5import { ChildProcessSpawner } from "effect/unstable/process";6import { ChildProcessSpawner } from "effect/unstable/process";
6 7
@@ -34,8 +35,8 @@ describe("tailscale endpoint provider", () => { · 27 unmodified lines
34 const dnsName = yield* parseTailscaleMagicDnsName(35 const dnsName = yield* parseTailscaleMagicDnsName(
35 `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,36 `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
36 );37 );
37 assert.equal(dnsName, "desktop.tail.ts.net");38 assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
38 assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);39 assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
39 const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));40 const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
40 assert.isTrue(malformed._tag === "Failure");41 assert.isTrue(malformed._tag === "Failure");
41 }),42 }),
apps/desktop/src/backend/tailscaleEndpointProvider.ts+6−6
@@ -61,17 +61,17 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { · 60 unmodified lines
61const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(61const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
62 "resolveTailscaleMagicDnsAdvertisedEndpoint",62 "resolveTailscaleMagicDnsAdvertisedEndpoint",
63)(function* (input: {63)(function* (input: {
64 readonly dnsName: string | null;64 readonly dnsName: Option.Option<string>;
65 readonly serveEnabled: boolean;65 readonly serveEnabled: boolean;
66 readonly servePort?: number;66 readonly servePort?: number;
67 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;67 readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
68}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {68}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
69 if (!input.dnsName) {69 if (Option.isNone(input.dnsName)) {
70 return Option.none();70 return Option.none();
71 }71 }
72 72
73 const httpBaseUrl = buildTailscaleHttpsBaseUrl({73 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
74 magicDnsName: input.dnsName,74 magicDnsName: input.dnsName.value,
75 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),75 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
76 });76 });
77 const probe =77 const probe =
@@ -116,13 +116,13 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd · 38 unmodified lines
116 input.statusJson === undefined116 input.statusJson === undefined
117 ? yield* readTailscaleStatus.pipe(117 ? yield* readTailscaleStatus.pipe(
118 Effect.map((status) => status.magicDnsName),118 Effect.map((status) => status.magicDnsName),
119 Effect.catch(() => Effect.succeed(null)),119 Effect.catch(() => Effect.succeed(Option.none())),
120 )120 )
121 : input.statusJson121 : input.statusJson
122 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(122 ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123 Effect.catch(() => Effect.succeed(null)),123 Effect.catch(() => Effect.succeed(Option.none())),
124 )124 )
125 : null;125 : Option.none();
126 const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({126 const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
127 dnsName,127 dnsName,
128 serveEnabled: input.serveEnabled === true,128 serveEnabled: input.serveEnabled === true,
packages/tailscale/src/tailscale.test.ts+48−9
@@ -1,8 +1,12 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
3import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
4import * as Sink from "effect/Sink";7import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";8import * as Stream from "effect/Stream";
9import * as TestClock from "effect/testing/TestClock";
6import { ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcessSpawner } from "effect/unstable/process";
7 11
8import {12import {
@@ -13,6 +17,7 @@ import { · 4 unmodified lines
13 parseTailscaleMagicDnsName,17 parseTailscaleMagicDnsName,
14 parseTailscaleStatus,18 parseTailscaleStatus,
15 readTailscaleStatus,19 readTailscaleStatus,
20 TAILSCALE_STATUS_TIMEOUT,
16} from "./tailscale.ts";21} from "./tailscale.ts";
17 22
18const encoder = new TextEncoder();23const encoder = new TextEncoder();
@@ -35,22 +40,37 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) · 16 unmodified lines
35 });40 });
36}41}
37 42
43function 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
38function mockSpawnerLayer(59function mockSpawnerLayer(
39 handler: (60 handler: (
40 command: string,61 command: string,
41 args: ReadonlyArray<string>,62 args: ReadonlyArray<string>,
42 ) => { stdout?: string; stderr?: string; code?: number },63 ) => { stdout?: string; stderr?: string; code?: number },
43) {64) {
44 return Layer.succeed(65 return Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
45 ChildProcessSpawner.ChildProcessSpawner,66 spawn: (command) => {
46 ChildProcessSpawner.make((command) => {
47 const childProcess = command as unknown as {67 const childProcess = command as unknown as {
48 readonly command: string;68 readonly command: string;
49 readonly args: ReadonlyArray<string>;69 readonly args: ReadonlyArray<string>;
50 };70 };
51 return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));71 return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
52 }),72 },
53 );73 });
54}74}
55 75
56describe("tailscale", () => {76describe("tailscale", () => {
@@ -66,8 +86,8 @@ describe("tailscale", () => { · 9 unmodified lines
66 it.effect("parses MagicDNS names from tailscale status", () =>86 it.effect("parses MagicDNS names from tailscale status", () =>
67 Effect.gen(function* () {87 Effect.gen(function* () {
68 const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson);88 const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson);
69 assert.equal(dnsName, "desktop.tail.ts.net");89 assert.deepEqual(dnsName, Option.some("desktop.tail.ts.net"));
70 assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);90 assert.deepEqual(yield* parseTailscaleMagicDnsName("{}"), Option.none());
71 }),91 }),
72 );92 );
73 93
@@ -75,7 +95,7 @@ describe("tailscale", () => { · 1 unmodified line
75 Effect.gen(function* () {95 Effect.gen(function* () {
76 const status = yield* parseTailscaleStatus(tailscaleStatusJson);96 const status = yield* parseTailscaleStatus(tailscaleStatusJson);
77 assert.deepEqual(status, {97 assert.deepEqual(status, {
78 magicDnsName: "desktop.tail.ts.net",98 magicDnsName: Option.some("desktop.tail.ts.net"),
79 tailnetIpv4Addresses: ["100.100.100.100"],99 tailnetIpv4Addresses: ["100.100.100.100"],
80 });100 });
81 }),101 }),
@@ -106,12 +126,31 @@ describe("tailscale", () => { · 24 unmodified lines
106 return Effect.gen(function* () {126 return Effect.gen(function* () {
107 const status = yield* readTailscaleStatus.pipe(Effect.provide(layer));127 const status = yield* readTailscaleStatus.pipe(Effect.provide(layer));
108 assert.deepEqual(status, {128 assert.deepEqual(status, {
109 magicDnsName: "desktop.tail.ts.net",129 magicDnsName: Option.some("desktop.tail.ts.net"),
110 tailnetIpv4Addresses: ["100.90.1.2"],130 tailnetIpv4Addresses: ["100.90.1.2"],
111 });131 });
112 });132 });
113 });133 });
114 134
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
115 it.effect("configures tailscale serve through the process spawner service", () => {154 it.effect("configures tailscale serve through the process spawner service", () => {
116 const layer = mockSpawnerLayer((command, args) => {155 const layer = mockSpawnerLayer((command, args) => {
117 assert.equal(command, "tailscale");156 assert.equal(command, "tailscale");
packages/tailscale/src/tailscale.ts+43−34
@@ -1,4 +1,4 @@
1import * as Data from "effect/Data";1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
@@ -7,24 +7,33 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 2 unmodified lines
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8 8
9export const DEFAULT_TAILSCALE_SERVE_PORT = 443;9export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
10export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;10export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500);
11export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;11export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
12export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;12export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
13 13
14export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{14export class TailscaleCommandError extends Schema.TaggedErrorClass<TailscaleCommandError>()(
15 readonly command: readonly string[];15 "TailscaleCommandError",
16 readonly message: string;16 {
17 readonly exitCode: number | null;17 command: Schema.Array(Schema.String),
18 readonly stderr: string;18 message: Schema.String,
19}> {}19 exitCode: Schema.Union([Schema.Number, Schema.Null]),
20 stderr: Schema.String,
21 },
22) {}
20 23
21export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{24export class TailscaleStatusParseError extends Schema.TaggedErrorClass<TailscaleStatusParseError>()(
22 readonly cause: unknown;25 "TailscaleStatusParseError",
23}> {}26 {
27 cause: Schema.Defect,
28 },
29) {}
24 30
25export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{31export class TailscaleUnavailableError extends Schema.TaggedErrorClass<TailscaleUnavailableError>()(
26 readonly reason: string;32 "TailscaleUnavailableError",
27}> {}33 {
34 reason: Schema.String,
35 },
36) {}
28 37
29const TailscaleStatusSelf = Schema.Struct({38const TailscaleStatusSelf = Schema.Struct({
30 DNSName: Schema.optional(Schema.Unknown),39 DNSName: Schema.optional(Schema.Unknown),
@@ -39,7 +48,7 @@ export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; · 8 unmodified lines
39export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;48export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;
40 49
41export interface TailscaleStatus {50export interface TailscaleStatus {
42 readonly magicDnsName: string | null;51 readonly magicDnsName: Option.Option<string>;
43 readonly tailnetIpv4Addresses: readonly string[];52 readonly tailnetIpv4Addresses: readonly string[];
44}53}
45 54
@@ -69,19 +78,19 @@ const tailscaleCommandError = ( · 23 unmodified lines
69 78
70const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));79const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));
71 80
72function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {81function normalizeMagicDnsName(status: TailscaleStatusJson): Option.Option<string> {
73 const dnsName = status.Self?.DNSName;82 const dnsName = status.Self?.DNSName;
74 if (typeof dnsName !== "string") {83 if (typeof dnsName !== "string") {
75 return null;84 return Option.none();
76 }85 }
77 86
78 const normalized = dnsName.trim().replace(/\.$/u, "");87 const normalized = dnsName.trim().replace(/\.$/u, "");
79 return normalized.length > 0 ? normalized : null;88 return normalized.length > 0 ? Option.some(normalized) : Option.none();
80}89}
81 90
82export const parseTailscaleMagicDnsName = (91export const parseTailscaleMagicDnsName = (
83 rawStatusJson: string,92 rawStatusJson: string,
84): Effect.Effect<string | null, TailscaleStatusParseError> =>93): Effect.Effect<Option.Option<string>, TailscaleStatusParseError> =>
85 decodeTailscaleStatusJson(rawStatusJson).pipe(94 decodeTailscaleStatusJson(rawStatusJson).pipe(
86 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),95 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
87 Effect.map(normalizeMagicDnsName),96 Effect.map(normalizeMagicDnsName),
@@ -177,7 +186,7 @@ export const readTailscaleStatus: Effect.Effect< · 89 unmodified lines
177 return yield* parseTailscaleStatus(stdout);186 return yield* parseTailscaleStatus(stdout);
178}).pipe(187}).pipe(
179 Effect.scoped,188 Effect.scoped,
180 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS),189 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT),
181 Effect.flatMap((result) =>190 Effect.flatMap((result) =>
182 Option.match(result, {191 Option.match(result, {
183 onNone: () =>192 onNone: () =>
@@ -209,7 +218,7 @@ const runTailscaleCommand = ( · 25 unmodified lines
209 readonly runMessage: string;218 readonly runMessage: string;
210 readonly exitMessage: (exitCode: number) => string;219 readonly exitMessage: (exitCode: number) => string;
211 readonly timeoutMessage: string;220 readonly timeoutMessage: string;
212 readonly timeoutMs: number;221 readonly timeout: Duration.Input;
213 },222 },
214): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>223): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
215 Effect.gen(function* () {224 Effect.gen(function* () {
@@ -246,7 +255,7 @@ const runTailscaleCommand = ( · 30 unmodified lines
246 }255 }
247 }).pipe(256 }).pipe(
248 Effect.scoped,257 Effect.scoped,
249 Effect.timeoutOption(input.timeoutMs),258 Effect.timeoutOption(input.timeout),
250 Effect.flatMap((result) =>259 Effect.flatMap((result) =>
251 Option.match(result, {260 Option.match(result, {
252 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),261 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
@@ -268,7 +277,7 @@ export const ensureTailscaleServe = (input: { · 15 unmodified lines
268 runMessage: "Failed to run tailscale serve.",277 runMessage: "Failed to run tailscale serve.",
269 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,278 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
270 timeoutMessage: "Tailscale serve timed out.",279 timeoutMessage: "Tailscale serve timed out.",
271 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,280 timeout: TAILSCALE_SERVE_TIMEOUT,
272 });281 });
273};282};
274 283
@@ -284,13 +293,13 @@ export const disableTailscaleServe = ( · 9 unmodified lines
284 runMessage: "Failed to run tailscale serve off.",293 runMessage: "Failed to run tailscale serve off.",
285 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,294 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,
286 timeoutMessage: "Tailscale serve off timed out.",295 timeoutMessage: "Tailscale serve off timed out.",
287 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,296 timeout: TAILSCALE_SERVE_TIMEOUT,
288 });297 });
289 });298 });
290 299
291export const probeTailscaleHttpsEndpoint = (input: {300export const probeTailscaleHttpsEndpoint = (input: {
292 readonly baseUrl: string;301 readonly baseUrl: string;
293 readonly timeoutMs?: number;302 readonly timeout?: Duration.Input;
294}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>303}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
295 Effect.gen(function* () {304 Effect.gen(function* () {
296 const client = yield* HttpClient.HttpClient;305 const client = yield* HttpClient.HttpClient;
@@ -298,7 +307,7 @@ export const probeTailscaleHttpsEndpoint = (input: { · 1 unmodified line
298 const url = new URL("/.well-known/t3/environment", input.baseUrl);307 const url = new URL("/.well-known/t3/environment", input.baseUrl);
299 const request = HttpClientRequest.get(url.toString());308 const request = HttpClientRequest.get(url.toString());
300 return yield* client.execute(request);309 return yield* client.execute(request);
301 }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS));310 }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));
302 311
303 return Option.match(response, {312 return Option.match(response, {
304 onNone: () => false,313 onNone: () => false,
@@ -311,17 +320,17 @@ export const resolveTailscaleHttpsBaseUrl = ( · 6 unmodified lines
311 readonly servePort?: number;320 readonly servePort?: number;
312 } = {},321 } = {},
313): Effect.Effect<322): Effect.Effect<
314 string | null,323 Option.Option<string>,
315 TailscaleCommandError | TailscaleStatusParseError,324 TailscaleCommandError | TailscaleStatusParseError,
316 ChildProcessSpawner.ChildProcessSpawner325 ChildProcessSpawner.ChildProcessSpawner
317> =>326> =>
318 readTailscaleStatus.pipe(327 readTailscaleStatus.pipe(
319 Effect.map((status) =>328 Effect.map((status) =>
320 status.magicDnsName329 Option.map(status.magicDnsName, (magicDnsName) =>
321 ? buildTailscaleHttpsBaseUrl({330 buildTailscaleHttpsBaseUrl({
322 magicDnsName: status.magicDnsName,331 magicDnsName,
323 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),332 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
324 })333 }),
325 : null,334 ),
326 ),335 ),
327 );336 );