History

[codex] refactor desktop backend Effect services (#3192)

Julius Marminge committed 4 months ago

Commit ffae12f1 parent 01cd56410 files · +257 −228Browse files

Unified · Split

10 files
  1. apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +1 −1
  2. apps/desktop/src/backend/DesktopBackendConfiguration.ts +42 −45
  3. apps/desktop/src/backend/DesktopBackendManager.ts +51 −32
  4. apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +2 −1
  5. apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +68 −57
  6. apps/desktop/src/backend/DesktopNetworkInterfaces.ts +33 −0
  7. apps/desktop/src/backend/DesktopServerExposure.test.ts +8 −7
  8. apps/desktop/src/backend/DesktopServerExposure.ts +45 −79
  9. apps/desktop/src/backend/tailscaleEndpointProvider.ts +5 −5
  10. apps/desktop/src/main.ts +2 −1
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+1−1
@@ -34,7 +34,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp · 33 unmodified lines
3434 setMode: () => Effect.die("unexpected setMode"),
3535 setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
3636 getAdvertisedEndpoints: Effect.succeed([]),
37−} satisfies DesktopServerExposure.DesktopServerExposureShape);
37+} satisfies DesktopServerExposure.DesktopServerExposure["Service"]);
3838
3939 function makeEnvironmentLayer(
4040 baseDir: string,
apps/desktop/src/backend/DesktopBackendConfiguration.ts+42−45
@@ -14,16 +14,14 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; · 13 unmodified lines
1414 import * as DesktopObservability from "../app/DesktopObservability.ts";
1515 import * as DesktopServerExposure from "./DesktopServerExposure.ts";
1616
17−export interface DesktopBackendConfigurationShape {
18− readonly resolve: Effect.Effect<
19− DesktopBackendManager.DesktopBackendStartConfig,
20− PlatformError.PlatformError
21− >;
22−}
23−
2417 export class DesktopBackendConfiguration extends Context.Service<
2518 DesktopBackendConfiguration,
26− DesktopBackendConfigurationShape
19+ {
20+ readonly resolve: Effect.Effect<
21+ DesktopBackendManager.DesktopBackendStartConfig,
22+ PlatformError.PlatformError
23+ >;
24+ }
2725 >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {}
2826
2927 interface BackendObservabilitySettings {
@@ -130,40 +128,39 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv · 100 unmodified lines
130128 },
131129 );
132130
133−export const layer = Layer.effect(
134− DesktopBackendConfiguration,
135− Effect.gen(function* () {
136− const environment = yield* DesktopEnvironment.DesktopEnvironment;
137− const fileSystem = yield* FileSystem.FileSystem;
138− const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
139− const crypto = yield* Crypto.Crypto;
140− const tokenRef = yield* Ref.make(Option.none<string>());
141− const getOrCreateBootstrapToken = Effect.gen(function* () {
142− const existing = yield* Ref.get(tokenRef);
143− if (Option.isSome(existing)) {
144− return existing.value;
145− }
146−
147− const token = Encoding.encodeHex(yield* crypto.randomBytes(24));
148− yield* Ref.set(tokenRef, Option.some(token));
149− return token;
150− });
151−
152− return DesktopBackendConfiguration.of({
153− resolve: Effect.gen(function* () {
154− const bootstrapToken = yield* getOrCreateBootstrapToken;
155− const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
156− Effect.provideService(FileSystem.FileSystem, fileSystem),
157− Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
158− );
159− return yield* resolveBackendStartConfig({
160− bootstrapToken,
161− observabilitySettings,
162− }).pipe(
163− Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
164− Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
165− );
166− }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")),
167− });
168− }),
169−);
131+export const make = Effect.gen(function* () {
132+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
133+ const fileSystem = yield* FileSystem.FileSystem;
134+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
135+ const crypto = yield* Crypto.Crypto;
136+ const tokenRef = yield* Ref.make(Option.none<string>());
137+ const getOrCreateBootstrapToken = Effect.gen(function* () {
138+ const existing = yield* Ref.get(tokenRef);
139+ if (Option.isSome(existing)) {
140+ return existing.value;
141+ }
142+
143+ const token = Encoding.encodeHex(yield* crypto.randomBytes(24));
144+ yield* Ref.set(tokenRef, Option.some(token));
145+ return token;
146+ });
147+
148+ return DesktopBackendConfiguration.of({
149+ resolve: Effect.gen(function* () {
150+ const bootstrapToken = yield* getOrCreateBootstrapToken;
151+ const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
152+ Effect.provideService(FileSystem.FileSystem, fileSystem),
153+ Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
154+ );
155+ return yield* resolveBackendStartConfig({
156+ bootstrapToken,
157+ observabilitySettings,
158+ }).pipe(
159+ Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
160+ Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
161+ );
162+ }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")),
163+ });
164+});
165+
166+export const layer = Layer.effect(DesktopBackendConfiguration, make);
apps/desktop/src/backend/DesktopBackendManager.ts+51−32
@@ -1,6 +1,5 @@
11 import * as Cause from "effect/Cause";
22 import * as Context from "effect/Context";
3−import * as Data from "effect/Data";
43 import * as Duration from "effect/Duration";
54 import * as Effect from "effect/Effect";
65 import * as Exit from "effect/Exit";
@@ -16,8 +15,9 @@ import * as Schema from "effect/Schema"; · 9 unmodified lines
1615 import * as Semaphore from "effect/Semaphore";
1716 import * as Scope from "effect/Scope";
1817 import * as Stream from "effect/Stream";
19−import { HttpClient } from "effect/unstable/http";
20−import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18+import * as HttpClient from "effect/unstable/http/HttpClient";
19+import * as ChildProcess from "effect/unstable/process/ChildProcess";
20+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
2121
2222 import {
2323 DesktopBackendBootstrap,
@@ -59,29 +59,38 @@ interface BackendProcessExit { · 35 unmodified lines
5959 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
6060 }
6161
62−export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{
63− readonly url: URL;
64−}> {
65− override get message() {
62+export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(
63+ "BackendTimeoutError",
64+ {
65+ url: Schema.URL,
66+ },
67+) {
68+ override get message(): string {
6669 return `Timed out waiting for backend readiness at ${this.url.href}.`;
6770 }
6871 }
6972
70−class BackendProcessBootstrapEncodeError extends Data.TaggedError(
73+class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
7174 "BackendProcessBootstrapEncodeError",
72−)<{
73− readonly cause: Schema.SchemaError;
74−}> {
75− override get message() {
76− return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`;
75+ {
76+ detail: Schema.String,
77+ cause: Schema.Defect(),
78+ },
79+) {
80+ override get message(): string {
81+ return `Failed to encode desktop backend bootstrap payload: ${this.detail}`;
7782 }
7883 }
7984
80−class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{
81− readonly cause: PlatformError.PlatformError;
82−}> {
83− override get message() {
84− return `Failed to spawn desktop backend process: ${this.cause.message}`;
85+class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
86+ "BackendProcessSpawnError",
87+ {
88+ detail: Schema.String,
89+ cause: Schema.Defect(),
90+ },
91+) {
92+ override get message(): string {
93+ return `Failed to spawn desktop backend process: ${this.detail}`;
8594 }
8695 }
8796
@@ -106,16 +115,14 @@ export interface DesktopBackendSnapshot { · 18 unmodified lines
106115 readonly restartScheduled: boolean;
107116 }
108117
109−export interface DesktopBackendManagerShape {
110− readonly start: Effect.Effect<void>;
111− readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;
112− readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;
113− readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;
114−}
115−
116118 export class DesktopBackendManager extends Context.Service<
117119 DesktopBackendManager,
118− DesktopBackendManagerShape
120+ {
121+ readonly start: Effect.Effect<void>;
122+ readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;
123+ readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;
124+ readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;
125+ }
119126 >()("@t3tools/desktop/backend/DesktopBackendManager") {}
120127
121128 const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } =
@@ -230,7 +237,13 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 108 unmodified lines
230237 ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
231238 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
232239 const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(
233− Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })),
240+ Effect.mapError(
241+ (cause) =>
242+ new BackendProcessBootstrapEncodeError({
243+ detail: cause.message,
244+ cause,
245+ }),
246+ ),
234247 );
235248 const onOutput = options.onOutput ?? (() => Effect.void);
236249 const command = ChildProcess.make(
@@ -256,9 +269,15 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 19 unmodified lines
256269 },
257270 );
258271
259− const handle = yield* spawner
260− .spawn(command)
261− .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause })));
272+ const handle = yield* spawner.spawn(command).pipe(
273+ Effect.mapError(
274+ (cause) =>
275+ new BackendProcessSpawnError({
276+ detail: cause.message,
277+ cause,
278+ }),
279+ ),
280+ );
262281
263282 yield* options.onStarted?.(handle.pid) ?? Effect.void;
264283 if (options.captureOutput) {
@@ -277,7 +296,7 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( · 12 unmodified lines
277296 return describeProcessExit(yield* Effect.result(handle.exitCode));
278297 });
279298
280−const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () {
299+export const make = Effect.gen(function* () {
281300 const parentScope = yield* Scope.Scope;
282301 const fileSystem = yield* FileSystem.FileSystem;
283302 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
@@ -603,4 +622,4 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio · 319 unmodified lines
603622 });
604623 });
605624
606−export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager());
625+export const layer = Layer.effect(DesktopBackendManager, make);
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts+2−1
@@ -3,7 +3,8 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
33 import * as Layer from "effect/Layer";
44 import * as Option from "effect/Option";
55 import * as Ref from "effect/Ref";
6−import { HttpClient, HttpClientResponse } from "effect/unstable/http";
6+import * as HttpClient from "effect/unstable/http/HttpClient";
7+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
78
89 import * as DesktopBackendManager from "./DesktopBackendManager.ts";
910 import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+68−57
@@ -1,77 +1,88 @@
11 import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
22 import * as Context from "effect/Context";
3−import * as Data from "effect/Data";
43 import * as Effect from "effect/Effect";
54 import * as Layer from "effect/Layer";
65 import * as Option from "effect/Option";
76 import * as Ref from "effect/Ref";
7+import * as Schema from "effect/Schema";
88 import * as Semaphore from "effect/Semaphore";
9−import { HttpClient } from "effect/unstable/http";
9+import * as HttpClient from "effect/unstable/http/HttpClient";
1010
1111 import * as DesktopBackendManager from "./DesktopBackendManager.ts";
1212
13−export interface DesktopLocalEnvironmentAuthShape {
14− readonly getBearerToken: Effect.Effect<string, DesktopLocalEnvironmentAuthError>;
13+export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()(
14+ "DesktopLocalEnvironmentAuthBackendNotConfiguredError",
15+ {},
16+) {
17+ override get message(): string {
18+ return "Local backend is not configured.";
19+ }
1520 }
1621
17−export class DesktopLocalEnvironmentAuthError extends Data.TaggedError(
18− "DesktopLocalEnvironmentAuthError",
19−)<{
20− readonly message: string;
21− readonly cause?: unknown;
22−}> {}
22+export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthSessionBootstrapError>()(
23+ "DesktopLocalEnvironmentAuthSessionBootstrapError",
24+ { cause: Schema.Defect() },
25+) {
26+ override get message(): string {
27+ return "Failed to create the local desktop bearer session.";
28+ }
29+}
30+
31+export const DesktopLocalEnvironmentAuthError = Schema.Union([
32+ DesktopLocalEnvironmentAuthBackendNotConfiguredError,
33+ DesktopLocalEnvironmentAuthSessionBootstrapError,
34+]);
35+export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type;
2336
2437 export class DesktopLocalEnvironmentAuth extends Context.Service<
2538 DesktopLocalEnvironmentAuth,
26− DesktopLocalEnvironmentAuthShape
39+ {
40+ readonly getBearerToken: Effect.Effect<string, DesktopLocalEnvironmentAuthError>;
41+ }
2742 >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {}
2843
29−export const layer = Layer.effect(
30− DesktopLocalEnvironmentAuth,
31− Effect.gen(function* () {
32− const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
33− const httpClient = yield* HttpClient.HttpClient;
34− const tokenRef = yield* Ref.make(Option.none<string>());
35− const mutex = yield* Semaphore.make(1);
44+export const make = Effect.gen(function* () {
45+ const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
46+ const httpClient = yield* HttpClient.HttpClient;
47+ const tokenRef = yield* Ref.make(Option.none<string>());
48+ const mutex = yield* Semaphore.make(1);
49+
50+ const getBearerToken = mutex
51+ .withPermits(1)(
52+ Effect.gen(function* () {
53+ const cached = yield* Ref.get(tokenRef);
54+ if (Option.isSome(cached)) {
55+ return cached.value;
56+ }
3657
37− const getBearerToken = mutex
38− .withPermits(1)(
39− Effect.gen(function* () {
40− const cached = yield* Ref.get(tokenRef);
41− if (Option.isSome(cached)) {
42− return cached.value;
43− }
58+ const configOption = yield* backendManager.currentConfig;
59+ if (Option.isNone(configOption)) {
60+ return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
61+ }
62+ const config = configOption.value;
63+ const session = yield* bootstrapRemoteBearerSession({
64+ httpBaseUrl: config.httpBaseUrl.href,
65+ credential: config.bootstrap.desktopBootstrapToken,
66+ clientMetadata: {
67+ label: "T3 Code Desktop",
68+ deviceType: "desktop",
69+ },
70+ }).pipe(
71+ Effect.provideService(HttpClient.HttpClient, httpClient),
72+ Effect.mapError(
73+ (cause) =>
74+ new DesktopLocalEnvironmentAuthSessionBootstrapError({
75+ cause,
76+ }),
77+ ),
78+ );
79+ yield* Ref.set(tokenRef, Option.some(session.access_token));
80+ return session.access_token;
81+ }),
82+ )
83+ .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken"));
4484
45− const configOption = yield* backendManager.currentConfig;
46− if (Option.isNone(configOption)) {
47− return yield* new DesktopLocalEnvironmentAuthError({
48− message: "Local backend is not configured.",
49− });
50− }
51− const config = configOption.value;
52− const session = yield* bootstrapRemoteBearerSession({
53− httpBaseUrl: config.httpBaseUrl.href,
54− credential: config.bootstrap.desktopBootstrapToken,
55− clientMetadata: {
56− label: "T3 Code Desktop",
57− deviceType: "desktop",
58− },
59− }).pipe(
60− Effect.provideService(HttpClient.HttpClient, httpClient),
61− Effect.mapError(
62− (cause) =>
63− new DesktopLocalEnvironmentAuthError({
64− message: "Failed to create the local desktop bearer session.",
65− cause,
66− }),
67− ),
68− );
69− yield* Ref.set(tokenRef, Option.some(session.access_token));
70− return session.access_token;
71− }),
72− )
73− .pipe(Effect.withSpan("desktop.localEnvironmentAuth.getBearerToken"));
85+ return DesktopLocalEnvironmentAuth.of({ getBearerToken });
86+});
7487
75− return DesktopLocalEnvironmentAuth.of({ getBearerToken });
76− }),
77−);
88+export const layer = Layer.effect(DesktopLocalEnvironmentAuth, make);
apps/desktop/src/backend/DesktopNetworkInterfaces.ts+33−0
@@ -0,0 +1,33 @@
1+import { networkInterfaces } from "node:os";
2+
3+import * as Context from "effect/Context";
4+import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
6+
7+export interface DesktopNetworkInterfaceInfo {
8+ readonly address: string;
9+ readonly family: string | number;
10+ readonly internal: boolean;
11+ readonly netmask?: string;
12+ readonly mac?: string;
13+ readonly cidr?: string | null;
14+ readonly scopeid?: number;
15+}
16+
17+export type NetworkInterfaces = Readonly<
18+ Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined>
19+>;
20+
21+export class DesktopNetworkInterfaces extends Context.Service<
22+ DesktopNetworkInterfaces,
23+ {
24+ readonly read: Effect.Effect<NetworkInterfaces>;
25+ }
26+>()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {}
27+
28+export const make = (): DesktopNetworkInterfaces["Service"] =>
29+ DesktopNetworkInterfaces.of({
30+ read: Effect.sync(() => networkInterfaces()),
31+ });
32+
33+export const layer = Layer.succeed(DesktopNetworkInterfaces, make());
apps/desktop/src/backend/DesktopServerExposure.test.ts+8−7
@@ -7,17 +7,18 @@ import * as FileSystem from "effect/FileSystem"; · 6 unmodified lines
77 import * as Layer from "effect/Layer";
88 import * as Sink from "effect/Sink";
99 import * as Stream from "effect/Stream";
10−import { ChildProcessSpawner } from "effect/unstable/process";
10+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1111
1212 import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1313 import * as DesktopConfig from "../app/DesktopConfig.ts";
14+import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
1415 import * as DesktopServerExposure from "./DesktopServerExposure.ts";
1516 import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1617
1718 const encoder = new TextEncoder();
1819
19−const emptyNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {};
20−const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {
20+const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {};
21+const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
2122 en0: [
2223 {
2324 address: "192.168.1.20",
@@ -27,7 +28,7 @@ const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { · 3 unmodified lines
2728 ],
2829 };
2930
30−const tailnetNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {
31+const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {
3132 tailscale0: [
3233 {
3334 address: "100.90.1.2",
@@ -87,13 +88,13 @@ function makeEnvironmentLayer(baseDir: string, env: Record<string, string | unde · 53 unmodified lines
8788
8889 function makeLayer(input: {
8990 readonly baseDir: string;
90− readonly networkInterfaces?: DesktopServerExposure.DesktopNetworkInterfaces;
91+ readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces;
9192 readonly env?: Record<string, string | undefined>;
9293 readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
9394 }) {
9495 const env = { T3CODE_HOME: input.baseDir, ...input.env };
9596 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
96− const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, {
97+ const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, {
9798 read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
9899 });
99100
@@ -109,7 +110,7 @@ function makeLayer(input: { · 9 unmodified lines
109110 }
110111
111112 const withHarness = <A, E, R>(
112− networkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces,
113+ networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
113114 effect: Effect.Effect<
114115 A,
115116 E,
apps/desktop/src/backend/DesktopServerExposure.ts+45−79
@@ -1,5 +1,3 @@
1−import * as NodeOS from "node:os";
2−
31 import {
42 createAdvertisedEndpoint,
53 type CreateAdvertisedEndpointInput,
@@ -10,41 +8,27 @@ import type { · 4 unmodified lines
108 DesktopServerExposureMode,
119 DesktopServerExposureState,
1210 } from "@t3tools/contracts";
11+import { readTailscaleStatus } from "@t3tools/tailscale";
1312 import * as Context from "effect/Context";
14−import * as Data from "effect/Data";
1513 import * as Duration from "effect/Duration";
1614 import * as Effect from "effect/Effect";
1715 import * as Layer from "effect/Layer";
1816 import * as Option from "effect/Option";
1917 import * as Ref from "effect/Ref";
20−import { HttpClient } from "effect/unstable/http";
21−import { ChildProcessSpawner } from "effect/unstable/process";
18+import * as Schema from "effect/Schema";
19+import * as HttpClient from "effect/unstable/http/HttpClient";
20+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
2221
23−import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
22+import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
2423 import * as DesktopConfig from "../app/DesktopConfig.ts";
24+import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
2525 import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
26−import { readTailscaleStatus } from "@t3tools/tailscale";
27−import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
2826
2927 const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60);
3028
3129 export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
3230 const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
3331
34−export interface DesktopNetworkInterfaceInfo {
35− readonly address: string;
36− readonly family: string | number;
37− readonly internal: boolean;
38− readonly netmask?: string;
39− readonly mac?: string;
40− readonly cidr?: string | null;
41− readonly scopeid?: number;
42−}
43−
44−export type DesktopNetworkInterfaces = Readonly<
45− Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined>
46−>;
47−
4832 interface ResolvedDesktopServerExposure {
4933 readonly mode: DesktopServerExposureMode;
5034 readonly bindHost: string;
@@ -91,7 +75,7 @@ const isHttpsEndpointUrl = (value: string): boolean => { · 40 unmodified lines
9175 };
9276
9377 const resolveLanAdvertisedHost = (
94− networkInterfaces: DesktopNetworkInterfaces,
78+ networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces,
9579 explicitHost: string | undefined,
9680 ): string | null => {
9781 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
@@ -116,7 +100,7 @@ const resolveLanAdvertisedHost = ( · 18 unmodified lines
116100 const resolveDesktopServerExposure = (input: {
117101 readonly mode: DesktopServerExposureMode;
118102 readonly port: number;
119− readonly networkInterfaces: DesktopNetworkInterfaces;
103+ readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces;
120104 readonly advertisedHostOverride?: string;
121105 }): ResolvedDesktopServerExposure => {
122106 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
@@ -218,25 +202,25 @@ const resolveDesktopCoreAdvertisedEndpoints = ( · 95 unmodified lines
218202 return endpoints;
219203 };
220204
221−type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve";
222−
223−export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError(
205+export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErrorClass<DesktopServerExposureNoNetworkAddressError>()(
224206 "DesktopServerExposureNoNetworkAddressError",
225−)<{
226− readonly port: number;
227−}> {
228− override get message() {
207+ {
208+ port: Schema.Number,
209+ },
210+) {
211+ override get message(): string {
229212 return `No reachable network address is available for desktop network access on port ${this.port}.`;
230213 }
231214 }
232215
233−export class DesktopServerExposurePersistenceError extends Data.TaggedError(
216+export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass<DesktopServerExposurePersistenceError>()(
234217 "DesktopServerExposurePersistenceError",
235−)<{
236− readonly operation: DesktopServerExposurePersistenceOperation;
237− readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError;
238−}> {
239− override get message() {
218+ {
219+ operation: Schema.Literals(["server-exposure-mode", "tailscale-serve"]),
220+ cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),
221+ },
222+) {
223+ override get message(): string {
240224 return `Failed to persist desktop ${this.operation} settings.`;
241225 }
242226 }
@@ -260,36 +244,25 @@ export interface DesktopServerExposureChange { · 17 unmodified lines
260244 readonly requiresRelaunch: boolean;
261245 }
262246
263−export interface DesktopServerExposureShape {
264− readonly getState: Effect.Effect<DesktopServerExposureState>;
265− readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>;
266− readonly configureFromSettings: (input: {
267− readonly port: number;
268− }) => Effect.Effect<DesktopServerExposureState>;
269− readonly setMode: (
270− mode: DesktopServerExposureMode,
271− ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>;
272− readonly setTailscaleServeEnabled: (input: {
273− readonly enabled: boolean;
274− readonly port?: number;
275− }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>;
276− readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
277−}
278−
279247 export class DesktopServerExposure extends Context.Service<
280248 DesktopServerExposure,
281− DesktopServerExposureShape
249+ {
250+ readonly getState: Effect.Effect<DesktopServerExposureState>;
251+ readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>;
252+ readonly configureFromSettings: (input: {
253+ readonly port: number;
254+ }) => Effect.Effect<DesktopServerExposureState>;
255+ readonly setMode: (
256+ mode: DesktopServerExposureMode,
257+ ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>;
258+ readonly setTailscaleServeEnabled: (input: {
259+ readonly enabled: boolean;
260+ readonly port?: number;
261+ }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>;
262+ readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
263+ }
282264 >()("@t3tools/desktop/backend/DesktopServerExposure") {}
283265
284−export interface DesktopNetworkInterfacesServiceShape {
285− readonly read: Effect.Effect<DesktopNetworkInterfaces>;
286−}
287−
288−export class DesktopNetworkInterfacesService extends Context.Service<
289− DesktopNetworkInterfacesService,
290− DesktopNetworkInterfacesServiceShape
291−>()("@t3tools/desktop/backend/DesktopServerExposure/DesktopNetworkInterfacesService") {}
292−
293266 interface RuntimeState {
294267 readonly requestedMode: DesktopServerExposureMode;
295268 readonly mode: DesktopServerExposureMode;
@@ -311,10 +284,10 @@ interface ResolvedRuntimeState { · 15 unmodified lines
311284
312285 const initialRuntimeState = (): RuntimeState =>
313286 runtimeStateFromResolvedExposure({
314− requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
315− settings: DEFAULT_DESKTOP_SETTINGS,
287+ requestedMode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
288+ settings: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
316289 exposure: resolveDesktopServerExposure({
317− mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
290+ mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
318291 port: 0,
319292 networkInterfaces: {},
320293 }),
@@ -348,7 +321,7 @@ const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure · 27 unmodified lines
348321
349322 function runtimeStateFromResolvedExposure(input: {
350323 readonly requestedMode: DesktopServerExposureMode;
351− readonly settings: DesktopSettings;
324+ readonly settings: DesktopAppSettings.DesktopSettings;
352325 readonly exposure: ResolvedDesktopServerExposure;
353326 readonly port: number;
354327 }): RuntimeState {
@@ -369,9 +342,9 @@ function runtimeStateFromResolvedExposure(input: { · 14 unmodified lines
369342
370343 function resolveRuntimeState(input: {
371344 readonly requestedMode: DesktopServerExposureMode;
372− readonly settings: DesktopSettings;
345+ readonly settings: DesktopAppSettings.DesktopSettings;
373346 readonly port: number;
374− readonly networkInterfaces: DesktopNetworkInterfaces;
347+ readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces;
375348 readonly advertisedHostOverride: Option.Option<string>;
376349 }): ResolvedRuntimeState {
377350 const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride);
@@ -408,12 +381,12 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo · 30 unmodified lines
408381 previous.bindHost !== next.bindHost ||
409382 previous.localHttpUrl !== next.localHttpUrl;
410383
411−const make = Effect.gen(function* () {
384+export const make = Effect.gen(function* () {
412385 const config = yield* DesktopConfig.DesktopConfig;
413− const networkInterfaces = yield* DesktopNetworkInterfacesService;
386+ const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces;
414387 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
415388 const httpClient = yield* HttpClient.HttpClient;
416− const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
389+ const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
417390 const stateRef = yield* Ref.make(initialRuntimeState());
418391
419392 // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App
@@ -564,10 +537,3 @@ const make = Effect.gen(function* () { · 144 unmodified lines
564537 });
565538
566539 export const layer = Layer.effect(DesktopServerExposure, make);
567−
568−export const networkInterfacesLayer = Layer.succeed(
569− DesktopNetworkInterfacesService,
570− DesktopNetworkInterfacesService.of({
571− read: Effect.sync(() => NodeOS.networkInterfaces()),
572− }),
573−);
apps/desktop/src/backend/tailscaleEndpointProvider.ts+5−5
@@ -9,10 +9,10 @@ import { · 8 unmodified lines
99 } from "@t3tools/tailscale";
1010 import * as Effect from "effect/Effect";
1111 import * as Option from "effect/Option";
12−import { HttpClient } from "effect/unstable/http";
13−import { ChildProcessSpawner } from "effect/unstable/process";
12+import * as HttpClient from "effect/unstable/http/HttpClient";
13+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1414
15−import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
15+import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts";
1616
1717 export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
1818
@@ -25,7 +25,7 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { · 6 unmodified lines
2525
2626 function resolveTailscaleIpAdvertisedEndpoints(input: {
2727 readonly port: number;
28− readonly networkInterfaces: DesktopNetworkInterfaces;
28+ readonly networkInterfaces: NetworkInterfaces;
2929 }): readonly AdvertisedEndpoint[] {
3030 const seen = new Set<string>();
3131 const endpoints: AdvertisedEndpoint[] = [];
@@ -103,7 +103,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd · 71 unmodified lines
103103 readonly port: number;
104104 readonly serveEnabled?: boolean;
105105 readonly servePort?: number;
106− readonly networkInterfaces: DesktopNetworkInterfaces;
106+ readonly networkInterfaces: NetworkInterfaces;
107107 readonly statusJson?: string | null;
108108 readonly readMagicDnsName?: Effect.Effect<
109109 string | null,
apps/desktop/src/main.ts+2−1
@@ -33,6 +33,7 @@ import * as DesktopAssets from "./app/DesktopAssets.ts"; · 32 unmodified lines
3333 import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
3434 import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts";
3535 import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts";
36+import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts";
3637 import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";
3738 import * as DesktopLifecycle from "./app/DesktopLifecycle.ts";
3839 import * as DesktopShutdown from "./app/DesktopShutdown.ts";
@@ -128,7 +129,7 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( · 89 unmodified lines
128129 );
129130
130131 const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
131− Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer),
132+ Layer.provideMerge(DesktopNetworkInterfaces.layer),
132133 Layer.provideMerge(desktopFoundationLayer),
133134 );
134135

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t