Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Improve idiomatic Effect usage in config and Tailscale paths #3073

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
cursor wants to merge 4 commits into main
base: main
Choose a base branch
Loading
from cursor/idiomatic-effect-patterns-eea2
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
43 changes: 43 additions & 0 deletions apps/server/src/vcs/VcsProjectConfig.test.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,47 @@ describe("VcsProjectConfig", () => {
}),
);
});

it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => {
it.effect("returns auto", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-vcs-config-test-",
});
const configDir = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDir, { recursive: true });
yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{not json");

const config = yield* VcsProjectConfig.VcsProjectConfig;
const kind = yield* config.resolveKind({ cwd: root });

assert.equal(kind, "auto");
}),
);
});

it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => {
it.effect("returns auto", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-vcs-config-test-",
});
const configDir = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDir, { recursive: true });
yield* fileSystem.writeFileString(
path.join(configDir, "vcs.json"),
`{"vcs":{"kind":"svn"}}`,
);

const config = yield* VcsProjectConfig.VcsProjectConfig;
const kind = yield* config.resolveKind({ cwd: root });

assert.equal(kind, "auto");
}),
);
});
});
48 changes: 19 additions & 29 deletions apps/server/src/vcs/VcsProjectConfig.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";

const ProjectVcsConfig = Schema.Struct({
vcs: Schema.optional(
Expand All @@ -15,16 +17,10 @@ const ProjectVcsConfig = Schema.Struct({
),
vcsKind: Schema.optional(VcsDriverKind),
});
const isProjectVcsConfig = Schema.is(ProjectVcsConfig);
const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);
const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);

interface ProjectVcsConfigFile {
readonly vcs?:
| {
readonly kind?: VcsDriverKindType | undefined;
}
| undefined;
readonly vcsKind?: VcsDriverKindType | undefined;
}
type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;

export interface VcsProjectConfigResolveInput {
readonly cwd: string;
Expand All @@ -45,14 +41,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
return config.vcs?.kind ?? config.vcsKind ?? "auto";
}

function parseConfig(raw: string): ProjectVcsConfigFile | null {
try {
const parsed = JSON.parse(raw) as unknown;
return isProjectVcsConfig(parsed) ? parsed : null;
} catch {
return null;
}
}
const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
decodeProjectVcsConfigJson(raw);

export const make = Effect.fn("makeVcsProjectConfig")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
while (true) {
const candidate = path.join(current, ".t3code", "vcs.json");
if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
return candidate;
return Option.some(candidate);
}

const parent = path.dirname(current);
if (parent === current) {
return null;
return Option.none();
}
current = parent;
}
Expand All @@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
configPath: string,
) {
const raw = yield* fileSystem.readFileString(configPath).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
Effect.logWarning("failed to read VCS project config", {
configPath,
error,
}).pipe(Effect.as(null)),
}).pipe(Effect.as(Option.none())),
),
);
if (raw === null) {
if (Option.isNone(raw)) {
return "auto" as const;
}

const parsed = parseConfig(raw);
if (parsed === null) {
const parsed = parseConfig(raw.value);
if (Option.isNone(parsed)) {
yield* Effect.logWarning("invalid VCS project config", {
configPath,
});
return "auto" as const;
}

return configuredKind(parsed);
return configuredKind(parsed.value);
});

const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
Expand All @@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
}

const configPath = yield* findConfigPath(input.cwd);
if (configPath === null) {
return "auto";
}

return yield* readConfiguredKind(configPath);
return yield* Option.match(configPath, {
onNone: () => Effect.succeed("auto" as const),
onSome: readConfiguredKind,
});
});

return VcsProjectConfig.of({
Expand Down
42 changes: 42 additions & 0 deletions packages/tailscale/src/tailscale.test.ts
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
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 +15,7 @@ import {
parseTailscaleMagicDnsName,
parseTailscaleStatus,
readTailscaleStatus,
TAILSCALE_STATUS_TIMEOUT,
} from "./tailscale.ts";

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

function neverFinishingMockHandle() {
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,
Expand Down Expand Up @@ -112,6 +131,29 @@ describe("tailscale", () => {
});
});

it.effect("times out tailscale status through TestClock", () => {
const layer = Layer.merge(
TestClock.layer(),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.succeed(neverFinishingMockHandle())),
),
);

return Effect.gen(function* () {
const fiber = yield* readTailscaleStatus.pipe(Effect.flip, Effect.forkScoped);
yield* Effect.yieldNow;
yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT);
const error = yield* Fiber.join(fiber);

if (error._tag !== "TailscaleCommandError") {
assert.fail(`Expected TailscaleCommandError, received ${error._tag}.`);
}
assert.equal(error.message, "Tailscale status timed out.");
assert.equal(error.exitCode, null);
}).pipe(Effect.provide(layer));
});

it.effect("configures tailscale serve through the process spawner service", () => {
const layer = mockSpawnerLayer((command, args) => {
assert.equal(command, "tailscale");
Expand Down
21 changes: 11 additions & 10 deletions packages/tailscale/src/tailscale.ts
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
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,9 +8,9 @@ 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);
const TAILSCALE_COMMAND = process.platform === "win32" ? "tailscale.exe" : "tailscale";

export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{
Expand Down Expand Up @@ -174,7 +175,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 @@ -206,7 +207,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 @@ -239,7 +240,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 @@ -261,7 +262,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 @@ -277,21 +278,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 Down
Loading

AltStyle によって変換されたページ (->オリジナル) /