| author | |
| committer | |
| log | ffae12f329c4de9680efd1757b488a3b5bbe3cd2 |
| tree | 74e065115f173b185201662e1bfcd87df2a8c5d7 |
| parent | 01cd564557c2e96019f7e7060869097bbe4f6805 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>10 files changed, 257 insertions(+), 228 deletions(-)
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+1-1| ... | ... | @@ -34,7 +34,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp |
| 34 | 34 | setMode: () => Effect.die("unexpected setMode"), |
| 35 | 35 | setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), |
| 36 | 36 | getAdvertisedEndpoints: Effect.succeed([]), |
| 37 | } satisfies DesktopServerExposure.DesktopServerExposureShape); | |
| 37 | } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); | |
| 38 | 38 | |
| 39 | 39 | function makeEnvironmentLayer( |
| 40 | 40 | baseDir: string, |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+42-45| ... | ... | @@ -14,16 +14,14 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 14 | 14 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 15 | 15 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 16 | 16 | |
| 17 | export interface DesktopBackendConfigurationShape { | |
| 18 | readonly resolve: Effect.Effect< | |
| 19 | DesktopBackendManager.DesktopBackendStartConfig, | |
| 20 | PlatformError.PlatformError | |
| 21 | >; | |
| 22 | } | |
| 23 | ||
| 24 | 17 | export class DesktopBackendConfiguration extends Context.Service< |
| 25 | 18 | DesktopBackendConfiguration, |
| 26 | DesktopBackendConfigurationShape | |
| 19 | { | |
| 20 | readonly resolve: Effect.Effect< | |
| 21 | DesktopBackendManager.DesktopBackendStartConfig, | |
| 22 | PlatformError.PlatformError | |
| 23 | >; | |
| 24 | } | |
| 27 | 25 | >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} |
| 28 | 26 | |
| 29 | 27 | interface BackendObservabilitySettings { |
| ... | ... | @@ -130,40 +128,39 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 130 | 128 | }, |
| 131 | 129 | ); |
| 132 | 130 | |
| 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 @@ |
| 1 | 1 | import * as Cause from "effect/Cause"; |
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | import * as Data from "effect/Data"; | |
| 4 | 3 | import * as Duration from "effect/Duration"; |
| 5 | 4 | import * as Effect from "effect/Effect"; |
| 6 | 5 | import * as Exit from "effect/Exit"; |
| ... | ... | @@ -16,8 +15,9 @@ import * as Schema from "effect/Schema"; |
| 16 | 15 | import * as Semaphore from "effect/Semaphore"; |
| 17 | 16 | import * as Scope from "effect/Scope"; |
| 18 | 17 | 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"; | |
| 21 | 21 | |
| 22 | 22 | import { |
| 23 | 23 | DesktopBackendBootstrap, |
| ... | ... | @@ -59,29 +59,38 @@ interface BackendProcessExit { |
| 59 | 59 | readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>; |
| 60 | 60 | } |
| 61 | 61 | |
| 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 { | |
| 66 | 69 | return `Timed out waiting for backend readiness at ${this.url.href}.`; |
| 67 | 70 | } |
| 68 | 71 | } |
| 69 | 72 | |
| 70 | class BackendProcessBootstrapEncodeError extends Data.TaggedError( | |
| 73 | class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()( | |
| 71 | 74 | "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}`; | |
| 77 | 82 | } |
| 78 | 83 | } |
| 79 | 84 | |
| 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}`; | |
| 85 | 94 | } |
| 86 | 95 | } |
| 87 | 96 | |
| ... | ... | @@ -106,16 +115,14 @@ export interface DesktopBackendSnapshot { |
| 106 | 115 | readonly restartScheduled: boolean; |
| 107 | 116 | } |
| 108 | 117 | |
| 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 | ||
| 116 | 118 | export class DesktopBackendManager extends Context.Service< |
| 117 | 119 | 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 | } | |
| 119 | 126 | >()("@t3tools/desktop/backend/DesktopBackendManager") {} |
| 120 | 127 | |
| 121 | 128 | const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = |
| ... | ... | @@ -230,7 +237,13 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( |
| 230 | 237 | ): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> { |
| 231 | 238 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 232 | 239 | 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 | ), | |
| 234 | 247 | ); |
| 235 | 248 | const onOutput = options.onOutput ?? (() => Effect.void); |
| 236 | 249 | const command = ChildProcess.make( |
| ... | ... | @@ -256,9 +269,15 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( |
| 256 | 269 | }, |
| 257 | 270 | ); |
| 258 | 271 | |
| 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 | ); | |
| 262 | 281 | |
| 263 | 282 | yield* options.onStarted?.(handle.pid) ?? Effect.void; |
| 264 | 283 | if (options.captureOutput) { |
| ... | ... | @@ -277,7 +296,7 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( |
| 277 | 296 | return describeProcessExit(yield* Effect.result(handle.exitCode)); |
| 278 | 297 | }); |
| 279 | 298 | |
| 280 | const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { | |
| 299 | export const make = Effect.gen(function* () { | |
| 281 | 300 | const parentScope = yield* Scope.Scope; |
| 282 | 301 | const fileSystem = yield* FileSystem.FileSystem; |
| 283 | 302 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| ... | ... | @@ -603,4 +622,4 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio |
| 603 | 622 | }); |
| 604 | 623 | }); |
| 605 | 624 | |
| 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"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | 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"; | |
| 7 | 8 | |
| 8 | 9 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 9 | 10 | import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; |
apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts+68-57| ... | ... | @@ -1,77 +1,88 @@ |
| 1 | 1 | import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; |
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | import * as Data from "effect/Data"; | |
| 4 | 3 | import * as Effect from "effect/Effect"; |
| 5 | 4 | import * as Layer from "effect/Layer"; |
| 6 | 5 | import * as Option from "effect/Option"; |
| 7 | 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | 8 | import * as Semaphore from "effect/Semaphore"; |
| 9 | import { HttpClient } from "effect/unstable/http"; | |
| 9 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 10 | 10 | |
| 11 | 11 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 12 | 12 | |
| 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 | } | |
| 15 | 20 | } |
| 16 | 21 | |
| 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; | |
| 23 | 36 | |
| 24 | 37 | export class DesktopLocalEnvironmentAuth extends Context.Service< |
| 25 | 38 | DesktopLocalEnvironmentAuth, |
| 26 | DesktopLocalEnvironmentAuthShape | |
| 39 | { | |
| 40 | readonly getBearerToken: Effect.Effect<string, DesktopLocalEnvironmentAuthError>; | |
| 41 | } | |
| 27 | 42 | >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} |
| 28 | 43 | |
| 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 | } | |
| 36 | 57 | |
| 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")); | |
| 44 | 84 | |
| 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 | }); | |
| 74 | 87 | |
| 75 | return DesktopLocalEnvironmentAuth.of({ getBearerToken }); | |
| 76 | }), | |
| 77 | ); | |
| 88 | export const layer = Layer.effect(DesktopLocalEnvironmentAuth, make); |
apps/desktop/src/backend/DesktopNetworkInterfaces.ts created+33| ... | ... | @@ -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"; |
| 7 | 7 | import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Sink from "effect/Sink"; |
| 9 | 9 | import * as Stream from "effect/Stream"; |
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 10 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 11 | 11 | |
| 12 | 12 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | 13 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 14 | import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; | |
| 14 | 15 | import * as DesktopServerExposure from "./DesktopServerExposure.ts"; |
| 15 | 16 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 16 | 17 | |
| 17 | 18 | const encoder = new TextEncoder(); |
| 18 | 19 | |
| 19 | const emptyNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = {}; | |
| 20 | const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { | |
| 20 | const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {}; | |
| 21 | const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { | |
| 21 | 22 | en0: [ |
| 22 | 23 | { |
| 23 | 24 | address: "192.168.1.20", |
| ... | ... | @@ -27,7 +28,7 @@ const lanNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { |
| 27 | 28 | ], |
| 28 | 29 | }; |
| 29 | 30 | |
| 30 | const tailnetNetworkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces = { | |
| 31 | const tailnetNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { | |
| 31 | 32 | tailscale0: [ |
| 32 | 33 | { |
| 33 | 34 | address: "100.90.1.2", |
| ... | ... | @@ -87,13 +88,13 @@ function makeEnvironmentLayer(baseDir: string, env: Record<string, string | unde |
| 87 | 88 | |
| 88 | 89 | function makeLayer(input: { |
| 89 | 90 | readonly baseDir: string; |
| 90 | readonly networkInterfaces?: DesktopServerExposure.DesktopNetworkInterfaces; | |
| 91 | readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; | |
| 91 | 92 | readonly env?: Record<string, string | undefined>; |
| 92 | 93 | readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; |
| 93 | 94 | }) { |
| 94 | 95 | const env = { T3CODE_HOME: input.baseDir, ...input.env }; |
| 95 | 96 | const environmentLayer = makeEnvironmentLayer(input.baseDir, env); |
| 96 | const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { | |
| 97 | const networkLayer = Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { | |
| 97 | 98 | read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), |
| 98 | 99 | }); |
| 99 | 100 | |
| ... | ... | @@ -109,7 +110,7 @@ function makeLayer(input: { |
| 109 | 110 | } |
| 110 | 111 | |
| 111 | 112 | const withHarness = <A, E, R>( |
| 112 | networkInterfaces: DesktopServerExposure.DesktopNetworkInterfaces, | |
| 113 | networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, | |
| 113 | 114 | effect: Effect.Effect< |
| 114 | 115 | A, |
| 115 | 116 | E, |
apps/desktop/src/backend/DesktopServerExposure.ts+45-79| ... | ... | @@ -1,5 +1,3 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | ||
| 3 | 1 | import { |
| 4 | 2 | createAdvertisedEndpoint, |
| 5 | 3 | type CreateAdvertisedEndpointInput, |
| ... | ... | @@ -10,41 +8,27 @@ import type { |
| 10 | 8 | DesktopServerExposureMode, |
| 11 | 9 | DesktopServerExposureState, |
| 12 | 10 | } from "@t3tools/contracts"; |
| 11 | import { readTailscaleStatus } from "@t3tools/tailscale"; | |
| 13 | 12 | import * as Context from "effect/Context"; |
| 14 | import * as Data from "effect/Data"; | |
| 15 | 13 | import * as Duration from "effect/Duration"; |
| 16 | 14 | import * as Effect from "effect/Effect"; |
| 17 | 15 | import * as Layer from "effect/Layer"; |
| 18 | 16 | import * as Option from "effect/Option"; |
| 19 | 17 | 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"; | |
| 22 | 21 | |
| 23 | import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; | |
| 22 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | |
| 24 | 23 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 24 | import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; | |
| 25 | 25 | import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; |
| 26 | import { readTailscaleStatus } from "@t3tools/tailscale"; | |
| 27 | import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; | |
| 28 | 26 | |
| 29 | 27 | const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); |
| 30 | 28 | |
| 31 | 29 | export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 32 | 30 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; |
| 33 | 31 | |
| 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 | ||
| 48 | 32 | interface ResolvedDesktopServerExposure { |
| 49 | 33 | readonly mode: DesktopServerExposureMode; |
| 50 | 34 | readonly bindHost: string; |
| ... | ... | @@ -91,7 +75,7 @@ const isHttpsEndpointUrl = (value: string): boolean => { |
| 91 | 75 | }; |
| 92 | 76 | |
| 93 | 77 | const resolveLanAdvertisedHost = ( |
| 94 | networkInterfaces: DesktopNetworkInterfaces, | |
| 78 | networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, | |
| 95 | 79 | explicitHost: string | undefined, |
| 96 | 80 | ): string | null => { |
| 97 | 81 | const normalizedExplicitHost = normalizeOptionalHost(explicitHost); |
| ... | ... | @@ -116,7 +100,7 @@ const resolveLanAdvertisedHost = ( |
| 116 | 100 | const resolveDesktopServerExposure = (input: { |
| 117 | 101 | readonly mode: DesktopServerExposureMode; |
| 118 | 102 | readonly port: number; |
| 119 | readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 103 | readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; | |
| 120 | 104 | readonly advertisedHostOverride?: string; |
| 121 | 105 | }): ResolvedDesktopServerExposure => { |
| 122 | 106 | const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; |
| ... | ... | @@ -218,25 +202,25 @@ const resolveDesktopCoreAdvertisedEndpoints = ( |
| 218 | 202 | return endpoints; |
| 219 | 203 | }; |
| 220 | 204 | |
| 221 | type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; | |
| 222 | ||
| 223 | export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( | |
| 205 | export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErrorClass<DesktopServerExposureNoNetworkAddressError>()( | |
| 224 | 206 | "DesktopServerExposureNoNetworkAddressError", |
| 225 | )<{ | |
| 226 | readonly port: number; | |
| 227 | }> { | |
| 228 | override get message() { | |
| 207 | { | |
| 208 | port: Schema.Number, | |
| 209 | }, | |
| 210 | ) { | |
| 211 | override get message(): string { | |
| 229 | 212 | return `No reachable network address is available for desktop network access on port ${this.port}.`; |
| 230 | 213 | } |
| 231 | 214 | } |
| 232 | 215 | |
| 233 | export class DesktopServerExposurePersistenceError extends Data.TaggedError( | |
| 216 | export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass<DesktopServerExposurePersistenceError>()( | |
| 234 | 217 | "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 { | |
| 240 | 224 | return `Failed to persist desktop ${this.operation} settings.`; |
| 241 | 225 | } |
| 242 | 226 | } |
| ... | ... | @@ -260,36 +244,25 @@ export interface DesktopServerExposureChange { |
| 260 | 244 | readonly requiresRelaunch: boolean; |
| 261 | 245 | } |
| 262 | 246 | |
| 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 | ||
| 279 | 247 | export class DesktopServerExposure extends Context.Service< |
| 280 | 248 | 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 | } | |
| 282 | 264 | >()("@t3tools/desktop/backend/DesktopServerExposure") {} |
| 283 | 265 | |
| 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 | ||
| 293 | 266 | interface RuntimeState { |
| 294 | 267 | readonly requestedMode: DesktopServerExposureMode; |
| 295 | 268 | readonly mode: DesktopServerExposureMode; |
| ... | ... | @@ -311,10 +284,10 @@ interface ResolvedRuntimeState { |
| 311 | 284 | |
| 312 | 285 | const initialRuntimeState = (): RuntimeState => |
| 313 | 286 | 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, | |
| 316 | 289 | exposure: resolveDesktopServerExposure({ |
| 317 | mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 290 | mode: DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 318 | 291 | port: 0, |
| 319 | 292 | networkInterfaces: {}, |
| 320 | 293 | }), |
| ... | ... | @@ -348,7 +321,7 @@ const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure |
| 348 | 321 | |
| 349 | 322 | function runtimeStateFromResolvedExposure(input: { |
| 350 | 323 | readonly requestedMode: DesktopServerExposureMode; |
| 351 | readonly settings: DesktopSettings; | |
| 324 | readonly settings: DesktopAppSettings.DesktopSettings; | |
| 352 | 325 | readonly exposure: ResolvedDesktopServerExposure; |
| 353 | 326 | readonly port: number; |
| 354 | 327 | }): RuntimeState { |
| ... | ... | @@ -369,9 +342,9 @@ function runtimeStateFromResolvedExposure(input: { |
| 369 | 342 | |
| 370 | 343 | function resolveRuntimeState(input: { |
| 371 | 344 | readonly requestedMode: DesktopServerExposureMode; |
| 372 | readonly settings: DesktopSettings; | |
| 345 | readonly settings: DesktopAppSettings.DesktopSettings; | |
| 373 | 346 | readonly port: number; |
| 374 | readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 347 | readonly networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces; | |
| 375 | 348 | readonly advertisedHostOverride: Option.Option<string>; |
| 376 | 349 | }): ResolvedRuntimeState { |
| 377 | 350 | const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); |
| ... | ... | @@ -408,12 +381,12 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo |
| 408 | 381 | previous.bindHost !== next.bindHost || |
| 409 | 382 | previous.localHttpUrl !== next.localHttpUrl; |
| 410 | 383 | |
| 411 | const make = Effect.gen(function* () { | |
| 384 | export const make = Effect.gen(function* () { | |
| 412 | 385 | const config = yield* DesktopConfig.DesktopConfig; |
| 413 | const networkInterfaces = yield* DesktopNetworkInterfacesService; | |
| 386 | const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; | |
| 414 | 387 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 415 | 388 | const httpClient = yield* HttpClient.HttpClient; |
| 416 | const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; | |
| 389 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 417 | 390 | const stateRef = yield* Ref.make(initialRuntimeState()); |
| 418 | 391 | |
| 419 | 392 | // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App |
| ... | ... | @@ -564,10 +537,3 @@ const make = Effect.gen(function* () { |
| 564 | 537 | }); |
| 565 | 538 | |
| 566 | 539 | 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 { |
| 9 | 9 | } from "@t3tools/tailscale"; |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | 11 | 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"; | |
| 14 | 14 | |
| 15 | import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; | |
| 15 | import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; | |
| 16 | 16 | |
| 17 | 17 | export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; |
| 18 | 18 | |
| ... | ... | @@ -25,7 +25,7 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { |
| 25 | 25 | |
| 26 | 26 | function resolveTailscaleIpAdvertisedEndpoints(input: { |
| 27 | 27 | readonly port: number; |
| 28 | readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 28 | readonly networkInterfaces: NetworkInterfaces; | |
| 29 | 29 | }): readonly AdvertisedEndpoint[] { |
| 30 | 30 | const seen = new Set<string>(); |
| 31 | 31 | const endpoints: AdvertisedEndpoint[] = []; |
| ... | ... | @@ -103,7 +103,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd |
| 103 | 103 | readonly port: number; |
| 104 | 104 | readonly serveEnabled?: boolean; |
| 105 | 105 | readonly servePort?: number; |
| 106 | readonly networkInterfaces: DesktopNetworkInterfaces; | |
| 106 | readonly networkInterfaces: NetworkInterfaces; | |
| 107 | 107 | readonly statusJson?: string | null; |
| 108 | 108 | readonly readMagicDnsName?: Effect.Effect< |
| 109 | 109 | string | null, |
apps/desktop/src/main.ts+2-1| ... | ... | @@ -33,6 +33,7 @@ import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 33 | 33 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| 34 | 34 | import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; |
| 35 | 35 | import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; |
| 36 | import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; | |
| 36 | 37 | import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; |
| 37 | 38 | import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; |
| 38 | 39 | import * as DesktopShutdown from "./app/DesktopShutdown.ts"; |
| ... | ... | @@ -128,7 +129,7 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( |
| 128 | 129 | ); |
| 129 | 130 | |
| 130 | 131 | const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( |
| 131 | Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), | |
| 132 | Layer.provideMerge(DesktopNetworkInterfaces.layer), | |
| 132 | 133 | Layer.provideMerge(desktopFoundationLayer), |
| 133 | 134 | ); |
| 134 | 135 |