diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index 0682b25ae38..b5bda400670 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -18,7 +18,7 @@ export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, }); } diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 8b959b40b15..769632a8fcb 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -10,8 +10,8 @@ import { import { ConnectionBlockedError, ConnectionTransientError, - ConnectionWakeups, Connectivity, + Wakeups, } from "@t3tools/client-runtime/connection"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { AuthStandardClientScopes } from "@t3tools/contracts"; @@ -41,53 +41,47 @@ function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "on return "unknown"; } -const connectivityLayer = Layer.succeed( - Connectivity, - Connectivity.of({ - status: Effect.tryPromise({ - try: () => Network.getNetworkStateAsync(), - catch: () => undefined, - }).pipe( - Effect.match({ - onFailure: () => "unknown" as const, - onSuccess: networkStatus, - }), - ), - changes: Stream.callback((queue) => +const connectivityLayer = Connectivity.layer({ + status: Effect.tryPromise({ + try: () => Network.getNetworkStateAsync(), + catch: () => undefined, + }).pipe( + Effect.match({ + onFailure: () => "unknown" as const, + onSuccess: networkStatus, + }), + ), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => + Network.addNetworkStateListener((state) => { + Queue.offerUnsafe(queue, networkStatus(state)); + }), + ), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), +}); + +const wakeupsLayer = Wakeups.layer({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => Effect.acquireRelease( Effect.sync(() => - Network.addNetworkStateListener((state) => { - Queue.offerUnsafe(queue, networkStatus(state)); + AppState.addEventListener("change", (state) => { + if (state === "active") { + Queue.offerUnsafe(queue, "application-active"); + } }), ), (subscription) => Effect.sync(() => subscription.remove()), ).pipe(Effect.asVoid), ), - }), -); - -const wakeupsLayer = Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ - changes: Stream.merge( - Stream.callback<"application-active">((queue) => - Effect.acquireRelease( - Effect.sync(() => - AppState.addEventListener("change", (state) => { - if (state === "active") { - Queue.offerUnsafe(queue, "application-active"); - } - }), - ), - (subscription) => Effect.sync(() => subscription.remove()), - ).pipe(Effect.asVoid), - ), - managedRelayAccountChanges(appAtomRegistry).pipe( - Stream.map(() => "credentials-changed" as const), - ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), ), - }), -); + ), +}); const capabilitiesLayer = Layer.succeedContext( Context.make( @@ -98,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Cloud to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -106,14 +100,14 @@ const capabilitiesLayer = Layer.succeedContext( (error) => new ConnectionTransientError({ reason: "network", - message: error.message, + detail: error.message, }), ), ); if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The T3 Cloud session is unavailable.", + detail: "The T3 Cloud session is unavailable.", }); } return token; @@ -132,7 +126,7 @@ const capabilitiesLayer = Layer.succeedContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not load the mobile device identity: ${String(cause)}`, + detail: `Could not load the mobile device identity: ${String(cause)}`, }), }).pipe(Effect.map(Option.some)), }), @@ -151,14 +145,14 @@ const capabilitiesLayer = Layer.succeedContext( Effect.fail( new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }), ), prepare: () => Effect.fail( new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }), ), disconnect: () => Effect.void, @@ -195,7 +189,19 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( }), ); -export const connectionPlatformLayer = Layer.mergeAll( +type ConnectionPlatformLayerSource = + | typeof connectionStorageLayer + | typeof connectivityLayer + | typeof wakeupsLayer + | typeof capabilitiesLayer + | typeof platformConnectionSourceLayer + | typeof environmentOwnedDataCleanupLayer; + +export const connectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error, + Layer.Services +> = Layer.mergeAll( connectionStorageLayer, connectivityLayer, wakeupsLayer, diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index 3b1eade0818..f35b938dc6c 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -1,16 +1,28 @@ -import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; +import { Connection } from "@t3tools/client-runtime/connection"; import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; import { runtimeContextLayer } from "../lib/runtime"; import { connectionPlatformLayer } from "./platform"; -const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( - Layer.provide(runtimeContextLayer), -); +const providedConnectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); + +type ConnectionLayerSource = + | typeof Connection.layer + | typeof runtimeContextLayer + | typeof providedConnectionPlatformLayer; -export const connectionLayer = clientConnectionLayer.pipe( +export const connectionLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Connection.layer.pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); -export const connectionAtomRuntime = Atom.runtime(connectionLayer); +export const connectionAtomRuntime: Atom.AtomRuntime< + Layer.Success, + Layer.Error +> = Atom.runtime(connectionLayer); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 5754d655633..276ea3c5c08 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -8,11 +8,11 @@ import { removeCatalogValue, replaceCatalogValue, } from "@t3tools/client-runtime/platform"; -import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { TokenStore } from "@t3tools/client-runtime/authorization"; import { - ConnectionCredentialStore, - ConnectionProfileStore, ConnectionTransientError, + CredentialStore, + ProfileStore, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, @@ -58,7 +58,7 @@ const LegacyStoredShellSnapshot = Schema.Struct({ function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, }); } @@ -197,7 +197,7 @@ export const connectionStorageLayer = Layer.effectContext( .update((document) => removeConnectionFromCatalog(document, target)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), }); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ProfileStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -221,7 +221,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = CredentialStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -248,7 +248,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const remoteTokenStore = RemoteDpopAccessTokenStore.of({ + const remoteTokenStore = TokenStore.make({ get: (environmentId) => catalog.read.pipe( Effect.map((document) => @@ -423,9 +423,9 @@ export const connectionStorageLayer = Layer.effectContext( return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), - Context.add(ConnectionProfileStore, profileStore), - Context.add(ConnectionCredentialStore, credentialStore), - Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(ProfileStore.ConnectionProfileStore, profileStore), + Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), + Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(EnvironmentCacheStore, cacheStore), ); }), diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index fe639d9c594..51251975557 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -15,9 +15,7 @@ import { HttpClient } from "effect/unstable/http"; import { afterEach, beforeEach, vi } from "vite-plus/test"; import { AVAILABLE_CONNECTION_STATE, - type EnvironmentRegistryService, EnvironmentSupervisor, - type EnvironmentSupervisorService, type PreparedConnection, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; @@ -114,13 +112,13 @@ function registryLayer(options?: { connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void, - } satisfies EnvironmentSupervisorService); + } satisfies EnvironmentSupervisor["Service"]); const registry = { run: (_environmentId: EnvironmentId, effect: Effect.Effect) => Effect.provideService(effect, EnvironmentSupervisor, supervisor), runStream: (_environmentId: EnvironmentId, stream: Stream.Stream) => Stream.provideService(stream, EnvironmentSupervisor, supervisor), - } as unknown as EnvironmentRegistryService; + } as unknown as EnvironmentRegistry["Service"]; return EnvironmentRegistry.of(registry); }), ); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index c8426d5510b..1b7cba5cbfd 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -10,11 +10,11 @@ import { import { ConnectionBlockedError, ConnectionTransientError, - ConnectionWakeups, Connectivity, mapRemoteEnvironmentError, PrimaryConnectionRegistration, PrimaryConnectionTarget, + Wakeups, } from "@t3tools/client-runtime/connection"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; @@ -50,56 +50,50 @@ function currentNetworkStatus(): "unknown" | "offline" | "online" { return navigator.onLine ? "online" : "offline"; } -const connectivityLayer = Layer.succeed( - Connectivity, - Connectivity.of({ - status: Effect.sync(currentNetworkStatus), - changes: Stream.callback((queue) => +const connectivityLayer = Connectivity.layer({ + status: Effect.sync(currentNetworkStatus), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const online = () => Queue.offerUnsafe(queue, "online"); + const offline = () => Queue.offerUnsafe(queue, "offline"); + window.addEventListener("online", online); + window.addEventListener("offline", offline); + return { online, offline }; + }), + ({ online, offline }) => + Effect.sync(() => { + window.removeEventListener("online", online); + window.removeEventListener("offline", offline); + }), + ).pipe(Effect.asVoid), + ), +}); + +const wakeupsLayer = Wakeups.layer({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => Effect.acquireRelease( Effect.sync(() => { - const online = () => Queue.offerUnsafe(queue, "online"); - const offline = () => Queue.offerUnsafe(queue, "offline"); - window.addEventListener("online", online); - window.addEventListener("offline", offline); - return { online, offline }; + const listener = () => { + if (document.visibilityState === "visible") { + Queue.offerUnsafe(queue, "application-active"); + } + }; + document.addEventListener("visibilitychange", listener); + return listener; }), - ({ online, offline }) => + (listener) => Effect.sync(() => { - window.removeEventListener("online", online); - window.removeEventListener("offline", offline); + document.removeEventListener("visibilitychange", listener); }), ).pipe(Effect.asVoid), ), - }), -); - -const wakeupsLayer = Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ - changes: Stream.merge( - Stream.callback<"application-active">((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const listener = () => { - if (document.visibilityState === "visible") { - Queue.offerUnsafe(queue, "application-active"); - } - }; - document.addEventListener("visibilitychange", listener); - return listener; - }), - (listener) => - Effect.sync(() => { - document.removeEventListener("visibilitychange", listener); - }), - ).pipe(Effect.asVoid), - ), - managedRelayAccountChanges(appAtomRegistry).pipe( - Stream.map(() => "credentials-changed" as const), - ), + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), ), - }), -); + ), +}); function clientMetadata() { const desktop = window.desktopBridge !== undefined; @@ -116,12 +110,12 @@ function sshPreparationError(cause: unknown) { if (message.toLowerCase().includes("cancel")) { return new ConnectionBlockedError({ reason: "authentication", - message, + detail: message, }); } return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not prepare the SSH environment: ${message}`, + detail: `Could not prepare the SSH environment: ${message}`, }); } @@ -139,7 +133,7 @@ export const provisionDesktopSshEnvironment = Effect.fn( if (pairingToken === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The SSH environment did not issue a pairing credential.", + detail: "The SSH environment did not issue a pairing credential.", }); } const descriptor = yield* Effect.tryPromise({ @@ -170,7 +164,7 @@ const capabilitiesLayer = Layer.effectContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Cloud to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -178,14 +172,14 @@ const capabilitiesLayer = Layer.effectContext( (error) => new ConnectionTransientError({ reason: "network", - message: error.message, + detail: error.message, }), ), ); if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The T3 Cloud session is unavailable.", + detail: "The T3 Cloud session is unavailable.", }); } return token; @@ -200,7 +194,7 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not load the desktop primary credential: ${String(cause)}`, + detail: `Could not load the desktop primary credential: ${String(cause)}`, }), }).pipe(Effect.map(Option.fromNullishOr)), }); @@ -210,7 +204,7 @@ const capabilitiesLayer = Layer.effectContext( if (bridge === undefined) { return yield* new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }); } return yield* provisionDesktopSshEnvironment(bridge, target); @@ -220,7 +214,7 @@ const capabilitiesLayer = Layer.effectContext( if (bridge === undefined) { return yield* new ConnectionBlockedError({ reason: "unsupported", - message: "SSH environments are only available in the desktop app.", + detail: "SSH environments are only available in the desktop app.", }); } const bootstrap = yield* Effect.tryPromise({ @@ -233,7 +227,7 @@ const capabilitiesLayer = Layer.effectContext( if (bootstrap.pairingToken === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The SSH environment did not issue a pairing credential.", + detail: "The SSH environment did not issue a pairing credential.", }); } const access = yield* Effect.tryPromise({ @@ -256,7 +250,7 @@ const capabilitiesLayer = Layer.effectContext( catch: (cause) => new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not disconnect the SSH environment: ${String(cause)}`, + detail: `Could not disconnect the SSH environment: ${String(cause)}`, }), }); }), @@ -278,7 +272,7 @@ const loadPrimaryConnectionRegistration = Effect.fn( if (resolved === null) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: "Unable to resolve the primary environment endpoint.", + detail: "Unable to resolve the primary environment endpoint.", }); } const descriptor = yield* fetchRemoteEnvironmentDescriptor({ @@ -342,7 +336,20 @@ const rpcRequestObserverLayer = Layer.succeed( }), ); -export const connectionPlatformLayer = Layer.mergeAll( +type ConnectionPlatformLayerSource = + | typeof connectionStorageLayer + | typeof connectivityLayer + | typeof wakeupsLayer + | typeof capabilitiesLayer + | typeof platformConnectionSourceLayer + | typeof environmentOwnedDataCleanupLayer + | typeof rpcRequestObserverLayer; + +export const connectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error, + Layer.Services +> = Layer.mergeAll( connectionStorageLayer, connectivityLayer, wakeupsLayer, diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index 3b1eade0818..f35b938dc6c 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -1,16 +1,28 @@ -import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; +import { Connection } from "@t3tools/client-runtime/connection"; import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; import { runtimeContextLayer } from "../lib/runtime"; import { connectionPlatformLayer } from "./platform"; -const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( - Layer.provide(runtimeContextLayer), -); +const providedConnectionPlatformLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); + +type ConnectionLayerSource = + | typeof Connection.layer + | typeof runtimeContextLayer + | typeof providedConnectionPlatformLayer; -export const connectionLayer = clientConnectionLayer.pipe( +export const connectionLayer: Layer.Layer< + Layer.Success, + Layer.Error +> = Connection.layer.pipe( Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), ); -export const connectionAtomRuntime = Atom.runtime(connectionLayer); +export const connectionAtomRuntime: Atom.AtomRuntime< + Layer.Success, + Layer.Error +> = Atom.runtime(connectionLayer); diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 0f0656dee98..6d503387bb6 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -43,7 +43,7 @@ describe("makeCatalogStore", () => { Effect.gen(function* () { const failure = new ConnectionTransientError({ reason: "remote-unavailable", - message: "permission denied", + detail: "permission denied", }); const store = yield* makeCatalogStore({ read: Effect.fail(failure), diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 19a4a8454ed..d118a428ed7 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -11,11 +11,11 @@ import { removeConnectionFromCatalog, replaceCatalogValue, } from "@t3tools/client-runtime/platform"; -import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { TokenStore } from "@t3tools/client-runtime/authorization"; import { - ConnectionCredentialStore, - ConnectionProfileStore, ConnectionTransientError, + CredentialStore, + ProfileStore, } from "@t3tools/client-runtime/connection"; import { EnvironmentId, @@ -63,7 +63,7 @@ const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson) function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", - message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, }); } @@ -343,7 +343,7 @@ export const connectionStorageLayer = Layer.effectContext( .update((document) => removeConnectionFromCatalog(document, target)) .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), }); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ProfileStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -367,7 +367,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = CredentialStore.make({ get: (connectionId) => catalog.read.pipe( Effect.map((document) => @@ -394,7 +394,7 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const remoteTokenStore = RemoteDpopAccessTokenStore.of({ + const remoteTokenStore = TokenStore.make({ get: (environmentId) => catalog.read.pipe( Effect.map((document) => @@ -527,9 +527,9 @@ export const connectionStorageLayer = Layer.effectContext( return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), - Context.add(ConnectionProfileStore, profileStore), - Context.add(ConnectionCredentialStore, credentialStore), - Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(ProfileStore.ConnectionProfileStore, profileStore), + Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), + Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(EnvironmentCacheStore, cacheStore), ); }), diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts index 06137d1fd5c..6236b5922d8 100644 --- a/packages/client-runtime/src/authorization/index.ts +++ b/packages/client-runtime/src/authorization/index.ts @@ -1,4 +1,7 @@ -export * from "./layer.ts"; export * from "./remote.ts"; -export * from "./service.ts"; -export * from "./tokenStore.ts"; +export { + type AuthorizedRemoteEnvironment, + type RelayEnvironmentAuthorization, + RemoteEnvironmentAuthorization, +} from "./service.ts"; +export * as TokenStore from "./tokenStore.ts"; diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index b65eacaa794..d950c241d50 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -5,12 +5,11 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; -import { ClientPresentation } from "../platform/capabilities.ts"; -import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts"; -import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; -import { remoteEnvironmentAuthorizationLayer } from "./layer.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "./service.ts"; +import * as TokenStore from "./tokenStore.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const ENDPOINT = { @@ -30,7 +29,7 @@ const DESCRIPTOR = { repositoryIdentity: true, }, }; -const BOOTSTRAP: RelayEnvironmentAuthorization = { +const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = { environmentId: ENVIRONMENT_ID, endpoint: ENDPOINT, credential: "relay-bootstrap", @@ -76,7 +75,7 @@ const authInvalid = () => ); const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { - readonly initialToken?: RemoteDpopAccessToken; + readonly initialToken?: TokenStore.RemoteDpopAccessToken; readonly responses: ReadonlyArray; }) { const tokens = yield* Ref.make( @@ -96,7 +95,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( >([]); const fetch = recordedFetch(input.responses); - const tokenStore = RemoteDpopAccessTokenStore.of({ + const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ get: (environmentId) => Ref.get(tokens).pipe( Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), @@ -114,23 +113,23 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( return next; }), }); - const signer = ManagedRelayDpopSigner.of({ + const signer = ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: Effect.succeed("thumbprint-1"), createProof: (proofInput) => Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( Effect.as(`proof:${proofInput.url}`), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })), ), }); - const layer = remoteEnvironmentAuthorizationLayer.pipe( + const layer = RemoteEnvironmentAuthorization.layer.pipe( Layer.provide( Layer.mergeAll( remoteHttpClientLayer(fetch.fetchFn), - Layer.succeed(ManagedRelayDpopSigner, signer), - Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, signer), + Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), Layer.succeed( - ClientPresentation, - ClientPresentation.of({ + ClientCapabilities.ClientPresentation, + ClientCapabilities.ClientPresentation.of({ metadata: { label: "T3 Code Test", deviceType: "mobile", @@ -159,7 +158,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( describe("RemoteEnvironmentAuthorization", () => { it.effect("reuses a valid persisted environment token without contacting the relay", () => Effect.gen(function* () { - const cached = new RemoteDpopAccessToken({ + const cached = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -173,7 +172,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, @@ -191,7 +190,7 @@ describe("RemoteEnvironmentAuthorization", () => { it.effect("refreshes and persists an expired environment token", () => Effect.gen(function* () { - const expired = new RemoteDpopAccessToken({ + const expired = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -209,7 +208,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, @@ -230,7 +229,7 @@ describe("RemoteEnvironmentAuthorization", () => { it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => Effect.gen(function* () { - const cached = new RemoteDpopAccessToken({ + const cached = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -249,7 +248,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, @@ -269,7 +268,7 @@ describe("RemoteEnvironmentAuthorization", () => { it.effect("refreshes a cached endpoint after consecutive transient failures", () => Effect.gen(function* () { - const cached = new RemoteDpopAccessToken({ + const cached = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: DESCRIPTOR.label, endpoint: ENDPOINT, @@ -289,7 +288,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); const authorized = yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; const firstFailure = yield* remote .authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, @@ -329,7 +328,7 @@ describe("RemoteEnvironmentAuthorization", () => { }); yield* Effect.gen(function* () { - const remote = yield* RemoteEnvironmentAuthorization; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return yield* remote.authorizeDpop({ expectedEnvironmentId: ENVIRONMENT_ID, obtainBootstrap: harness.obtainBootstrap, diff --git a/packages/client-runtime/src/authorization/layer.ts b/packages/client-runtime/src/authorization/layer.ts deleted file mode 100644 index 9b71edf0461..00000000000 --- a/packages/client-runtime/src/authorization/layer.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { - exchangeRemoteDpopAccessToken, - type RemoteEnvironmentAuthError, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, -} from "./remote.ts"; -import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; -import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; -import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; -import { environmentEndpointUrl } from "../environment/endpoint.ts"; -import { ClientPresentation } from "../platform/capabilities.ts"; -import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; -import { RemoteEnvironmentAuthorization } from "./service.ts"; -import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import { HttpClient } from "effect/unstable/http"; - -const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; -const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; - -function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { - return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" - ? error - : mapRemoteEnvironmentError(error); -} - -const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( - httpBaseUrl: string, -) { - return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( - Effect.mapError(mapRemoteEnvironmentError), - ); -}); - -export const remoteEnvironmentAuthorizationLayer = Layer.effect( - RemoteEnvironmentAuthorization, - Effect.gen(function* () { - const signer = yield* ManagedRelayDpopSigner; - const presentation = yield* ClientPresentation; - const tokenStore = yield* RemoteDpopAccessTokenStore; - const httpClient = yield* HttpClient.HttpClient; - const cachedEndpointFailures = yield* Ref.make>(new Map()); - - const resetCachedEndpointFailures = (environmentId: string) => - Ref.update(cachedEndpointFailures, (current) => { - if (!current.has(environmentId)) { - return current; - } - const next = new Map(current); - next.delete(environmentId); - return next; - }); - - const recordCachedEndpointFailure = (environmentId: string) => - Ref.modify(cachedEndpointFailures, (current) => { - const failureCount = (current.get(environmentId) ?? 0) + 1; - const next = new Map(current); - next.set(environmentId, failureCount); - return [failureCount, next] as const; - }); - - const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( - function* (input: { - readonly expectedEnvironmentId: Parameters< - RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] - >[0]["expectedEnvironmentId"]; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly bearerToken: string; - }) { - const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - ); - if (descriptor.environmentId !== input.expectedEnvironmentId) { - return yield* environmentMismatchError({ - expected: input.expectedEnvironmentId, - actual: descriptor.environmentId, - }); - } - const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: input.wsBaseUrl, - httpBaseUrl: input.httpBaseUrl, - bearerToken: input.bearerToken, - }).pipe( - Effect.mapError(mapRemoteEnvironmentError), - Effect.provideService(HttpClient.HttpClient, httpClient), - ); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: input.httpBaseUrl, - socketUrl, - httpAuthorization: { - _tag: "Bearer" as const, - token: input.bearerToken, - }, - }; - }, - ); - - const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( - function* (token: RemoteDpopAccessToken) { - const ticketProof = yield* signer - .createProof({ - method: "POST", - url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), - accessToken: token.accessToken, - }) - .pipe( - Effect.mapError( - () => - new ConnectionBlockedError({ - reason: "configuration", - message: "Could not create the websocket authorization proof.", - }), - ), - ); - return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: token.endpoint.wsBaseUrl, - httpBaseUrl: token.endpoint.httpBaseUrl, - accessToken: token.accessToken, - dpopProof: ticketProof, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); - }, - ); - - const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( - function* (input: { - readonly expectedEnvironmentId: Parameters< - RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] - >[0]["expectedEnvironmentId"]; - readonly obtainBootstrap: Parameters< - RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] - >[0]["obtainBootstrap"]; - }) { - const thumbprint = yield* signer.thumbprint.pipe( - Effect.mapError( - () => - new ConnectionBlockedError({ - reason: "configuration", - message: "Could not load the environment authorization key.", - }), - ), - Effect.withSpan("environment.authorization.dpopKey.resolve"), - ); - const now = yield* Clock.currentTimeMillis; - const cached = yield* tokenStore - .get(input.expectedEnvironmentId) - .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); - if ( - Option.isSome(cached) && - cached.value.environmentId === input.expectedEnvironmentId && - cached.value.dpopThumbprint === thumbprint && - cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS - ) { - yield* Effect.annotateCurrentSpan({ - "connection.remote_token_cache": "hit", - }); - const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); - if (Result.isSuccess(cachedSocket)) { - yield* resetCachedEndpointFailures(input.expectedEnvironmentId); - return { - environmentId: cached.value.environmentId, - label: cached.value.label, - httpBaseUrl: cached.value.endpoint.httpBaseUrl, - socketUrl: cachedSocket.success, - httpAuthorization: { - _tag: "Dpop" as const, - accessToken: cached.value.accessToken, - }, - }; - } - if (cachedSocket.failure._tag === "ConnectionBlockedError") { - return yield* mapDpopSocketError(cachedSocket.failure); - } - const mappedFailure = mapDpopSocketError(cachedSocket.failure); - if (mappedFailure._tag === "ConnectionTransientError") { - const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); - if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { - return yield* mappedFailure; - } - } - yield* tokenStore - .remove(input.expectedEnvironmentId) - .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); - yield* resetCachedEndpointFailures(input.expectedEnvironmentId); - } - - yield* resetCachedEndpointFailures(input.expectedEnvironmentId); - yield* Effect.annotateCurrentSpan({ - "connection.remote_token_cache": "miss", - }); - const bootstrap = yield* input.obtainBootstrap; - const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.withSpan("environment.authorization.descriptor"), - ); - if (descriptor.environmentId !== input.expectedEnvironmentId) { - return yield* environmentMismatchError({ - expected: input.expectedEnvironmentId, - actual: descriptor.environmentId, - }); - } - const bootstrapProof = yield* signer - .createProof({ - method: "POST", - url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), - }) - .pipe( - Effect.mapError( - () => - new ConnectionBlockedError({ - reason: "configuration", - message: "Could not create the environment authorization proof.", - }), - ), - ); - const access = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: bootstrap.endpoint.httpBaseUrl, - credential: bootstrap.credential, - dpopProof: bootstrapProof, - scopes: presentation.scopes, - clientMetadata: presentation.metadata, - }).pipe( - Effect.mapError(mapRemoteEnvironmentError), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.withSpan("environment.authorization.accessToken.exchange"), - ); - const issuedAt = yield* Clock.currentTimeMillis; - const token = new RemoteDpopAccessToken({ - environmentId: descriptor.environmentId, - label: descriptor.label, - endpoint: bootstrap.endpoint, - accessToken: access.access_token, - expiresAtEpochMs: issuedAt + access.expires_in * 1_000, - dpopThumbprint: thumbprint, - }); - const socketUrl = yield* createDpopSocketUrl(token).pipe( - Effect.mapError(mapDpopSocketError), - ); - yield* tokenStore - .put(token) - .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: bootstrap.endpoint.httpBaseUrl, - socketUrl, - httpAuthorization: { - _tag: "Dpop" as const, - accessToken: token.accessToken, - }, - }; - }, - ); - - return RemoteEnvironmentAuthorization.of({ - authorizeBearer, - authorizeDpop: (input) => - authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), - }); - }), -); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 2a39edfd074..624ecf7f672 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -1,9 +1,28 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import { + exchangeRemoteDpopAccessToken, + type RemoteEnvironmentAuthError, + resolveRemoteDpopWebSocketConnectionUrl, + resolveRemoteWebSocketConnectionUrl, +} from "./remote.ts"; +import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as TokenStore from "./tokenStore.ts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as HttpClient from "effect/unstable/http/HttpClient"; -import type { ConnectionAttemptError, PreparedHttpAuthorization } from "../connection/model.ts"; +import type { PreparedHttpAuthorization } from "../connection/model.ts"; export interface RelayEnvironmentAuthorization { readonly environmentId: EnvironmentId; @@ -37,3 +56,247 @@ export class RemoteEnvironmentAuthorization extends Context.Service< }) => Effect.Effect; } >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} + +const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; +const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; + +function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { + return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" + ? error + : mapRemoteEnvironmentError(error); +} + +const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( + httpBaseUrl: string, +) { + return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + ); +}); + +export const make = Effect.gen(function* () { + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; + const presentation = yield* ClientCapabilities.ClientPresentation; + const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; + const httpClient = yield* HttpClient.HttpClient; + const cachedEndpointFailures = yield* Ref.make>(new Map()); + + const resetCachedEndpointFailures = (environmentId: string) => + Ref.update(cachedEndpointFailures, (current) => { + if (!current.has(environmentId)) { + return current; + } + const next = new Map(current); + next.delete(environmentId); + return next; + }); + + const recordCachedEndpointFailure = (environmentId: string) => + Ref.modify(cachedEndpointFailures, (current) => { + const failureCount = (current.get(environmentId) ?? 0) + 1; + const next = new Map(current); + next.set(environmentId, failureCount); + return [failureCount, next] as const; + }); + + const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] + >[0]["expectedEnvironmentId"]; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) { + const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: input.wsBaseUrl, + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }; + }, + ); + + const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( + function* (token: TokenStore.RemoteDpopAccessToken) { + const ticketProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the websocket authorization proof.", + }), + ), + ); + return yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: token.endpoint.wsBaseUrl, + httpBaseUrl: token.endpoint.httpBaseUrl, + accessToken: token.accessToken, + dpopProof: ticketProof, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + }, + ); + + const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["expectedEnvironmentId"]; + readonly obtainBootstrap: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["obtainBootstrap"]; + }) { + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not load the environment authorization key.", + }), + ), + Effect.withSpan("environment.authorization.dpopKey.resolve"), + ); + const now = yield* Clock.currentTimeMillis; + const cached = yield* tokenStore + .get(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); + if ( + Option.isSome(cached) && + cached.value.environmentId === input.expectedEnvironmentId && + cached.value.dpopThumbprint === thumbprint && + cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + ) { + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "hit", + }); + const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); + if (Result.isSuccess(cachedSocket)) { + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + return { + environmentId: cached.value.environmentId, + label: cached.value.label, + httpBaseUrl: cached.value.endpoint.httpBaseUrl, + socketUrl: cachedSocket.success, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: cached.value.accessToken, + }, + }; + } + if (cachedSocket.failure._tag === "ConnectionBlockedError") { + return yield* mapDpopSocketError(cachedSocket.failure); + } + const mappedFailure = mapDpopSocketError(cachedSocket.failure); + if (mappedFailure._tag === "ConnectionTransientError") { + const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); + if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { + return yield* mappedFailure; + } + } + yield* tokenStore + .remove(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + } + + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "miss", + }); + const bootstrap = yield* input.obtainBootstrap; + const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.descriptor"), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const bootstrapProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the environment authorization proof.", + }), + ), + ); + const access = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + credential: bootstrap.credential, + dpopProof: bootstrapProof, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.accessToken.exchange"), + ); + const issuedAt = yield* Clock.currentTimeMillis; + const token = new TokenStore.RemoteDpopAccessToken({ + environmentId: descriptor.environmentId, + label: descriptor.label, + endpoint: bootstrap.endpoint, + accessToken: access.access_token, + expiresAtEpochMs: issuedAt + access.expires_in * 1_000, + dpopThumbprint: thumbprint, + }); + const socketUrl = yield* createDpopSocketUrl(token).pipe(Effect.mapError(mapDpopSocketError)); + yield* tokenStore + .put(token) + .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: token.accessToken, + }, + }; + }, + ); + + return RemoteEnvironmentAuthorization.of({ + authorizeBearer, + authorizeDpop: (input) => + authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), + }); +}); + +export const layer = Layer.effect(RemoteEnvironmentAuthorization, make); diff --git a/packages/client-runtime/src/authorization/tokenStore.ts b/packages/client-runtime/src/authorization/tokenStore.ts index e00cc4cfdff..c490a22da13 100644 --- a/packages/client-runtime/src/authorization/tokenStore.ts +++ b/packages/client-runtime/src/authorization/tokenStore.ts @@ -2,6 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { RelayManagedEndpoint } from "@t3tools/contracts/relay"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -28,3 +29,9 @@ export class RemoteDpopAccessTokenStore extends Context.Service< readonly remove: (environmentId: EnvironmentId) => Effect.Effect; } >()("@t3tools/client-runtime/authorization/tokenStore/RemoteDpopAccessTokenStore") {} + +export const make = (service: RemoteDpopAccessTokenStore["Service"]) => + RemoteDpopAccessTokenStore.of(service); + +export const layer = (service: RemoteDpopAccessTokenStore["Service"]) => + Layer.succeed(RemoteDpopAccessTokenStore, make(service)); diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 2a94ab70454..84f81153194 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -1,10 +1,7 @@ import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import type { ConnectionAttemptError } from "./model.ts"; import { BearerConnectionTarget, PrimaryConnectionTarget, @@ -116,28 +113,3 @@ export function connectionRegistrationCatalogEntry( }; } } - -export class ConnectionProfileStore extends Context.Service< - ConnectionProfileStore, - { - readonly get: ( - connectionId: string, - ) => Effect.Effect, ConnectionAttemptError>; - readonly put: (profile: ConnectionProfile) => Effect.Effect; - readonly remove: (connectionId: string) => Effect.Effect; - } ->()("@t3tools/client-runtime/connection/catalog/ConnectionProfileStore") {} - -export class ConnectionCredentialStore extends Context.Service< - ConnectionCredentialStore, - { - readonly get: ( - connectionId: string, - ) => Effect.Effect, ConnectionAttemptError>; - readonly put: ( - connectionId: string, - credential: ConnectionCredential, - ) => Effect.Effect; - readonly remove: (connectionId: string) => Effect.Effect; - } ->()("@t3tools/client-runtime/connection/catalog/ConnectionCredentialStore") {} diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts index 44b38a3082e..6b40680ce35 100644 --- a/packages/client-runtime/src/connection/connectivity.ts +++ b/packages/client-runtime/src/connection/connectivity.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import type * as Stream from "effect/Stream"; import type { NetworkStatus } from "./model.ts"; @@ -11,3 +12,8 @@ export class Connectivity extends Context.Service< readonly changes: Stream.Stream; } >()("@t3tools/client-runtime/connection/connectivity") {} + +export const make = (service: Connectivity["Service"]) => Connectivity.of(service); + +export const layer = (service: Connectivity["Service"]) => + Layer.succeed(Connectivity, make(service)); diff --git a/packages/client-runtime/src/connection/credentialStore.ts b/packages/client-runtime/src/connection/credentialStore.ts new file mode 100644 index 00000000000..0107bc91fb1 --- /dev/null +++ b/packages/client-runtime/src/connection/credentialStore.ts @@ -0,0 +1,27 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Option from "effect/Option"; + +import type { ConnectionCredential } from "./catalog.ts"; +import type { ConnectionAttemptError } from "./model.ts"; + +export class ConnectionCredentialStore extends Context.Service< + ConnectionCredentialStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: ( + connectionId: string, + credential: ConnectionCredential, + ) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/credentialStore/ConnectionCredentialStore") {} + +export const make = (service: ConnectionCredentialStore["Service"]) => + ConnectionCredentialStore.of(service); + +export const layer = (service: ConnectionCredentialStore["Service"]) => + Layer.succeed(ConnectionCredentialStore, make(service)); diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index c1a8f67a759..f29f913dd54 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -9,8 +9,8 @@ import type { ConnectionAttemptStage, PreparedConnection, } from "./model.ts"; -import { ConnectionResolver } from "./resolver.ts"; -import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as RpcSession from "../rpc/session.ts"; export type ConnectionDriverProgress = | { @@ -23,44 +23,42 @@ export type ConnectionDriverProgress = export interface EnvironmentConnectionLease { readonly prepared: PreparedConnection; - readonly session: RpcSession; + readonly session: RpcSession.RpcSession; } -export interface ConnectionDriverService { - readonly connect: ( - entry: ConnectionCatalogEntry, - reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, - ) => Effect.Effect; -} - -export class ConnectionDriver extends Context.Service()( - "@t3tools/client-runtime/connection/driver/ConnectionDriver", -) {} - -export const connectionDriverLayer = Layer.effect( +export class ConnectionDriver extends Context.Service< ConnectionDriver, - Effect.gen(function* () { - const resolver = yield* ConnectionResolver; - const sessions = yield* RpcSessionFactory; - - const connect = Effect.fn("ConnectionDriver.connect")(function* ( + { + readonly connect: ( entry: ConnectionCatalogEntry, reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, - ) { - const target = entry.target; - yield* Effect.annotateCurrentSpan({ - "connection.environment.id": target.environmentId, - "connection.target.kind": target._tag, - }); - yield* reportProgress({ stage: "preparing" }); - const prepared = yield* resolver.prepare(entry); - yield* reportProgress({ stage: "opening", prepared }); - const session = yield* sessions.connect(prepared); - yield* reportProgress({ stage: "synchronizing", prepared }); - yield* session.ready; - return { prepared, session } satisfies EnvironmentConnectionLease; + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} + +export const make = Effect.gen(function* () { + const resolver = yield* ConnectionResolver.ConnectionResolver; + const sessions = yield* RpcSession.RpcSessionFactory; + + const connect = Effect.fn("ConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, }); + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* resolver.prepare(entry); + yield* reportProgress({ stage: "opening", prepared }); + const session = yield* sessions.connect(prepared); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies EnvironmentConnectionLease; + }); + + return ConnectionDriver.of({ connect }); +}); - return ConnectionDriver.of({ connect }); - }), -); +export const layer = Layer.effect(ConnectionDriver, make); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index ab5baec3364..5d9d361c06d 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -11,14 +11,14 @@ import { export function profileMissingError(connectionId: string): ConnectionBlockedError { return new ConnectionBlockedError({ reason: "configuration", - message: `Connection profile ${connectionId} is unavailable.`, + detail: `Connection profile ${connectionId} is unavailable.`, }); } export function credentialMissingError(connectionId: string): ConnectionBlockedError { return new ConnectionBlockedError({ reason: "authentication", - message: `Connection credential ${connectionId} is unavailable.`, + detail: `Connection credential ${connectionId} is unavailable.`, }); } @@ -28,7 +28,7 @@ export function environmentMismatchError(input: { }): ConnectionBlockedError { return new ConnectionBlockedError({ reason: "configuration", - message: `Connected environment ${input.actual} does not match ${input.expected}.`, + detail: `Connected environment ${input.actual} does not match ${input.expected}.`, }); } @@ -40,34 +40,34 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError case "RelayAgentActivityPublishProofInvalidError": return new ConnectionBlockedError({ reason: "authentication", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentConnectNotAuthorizedError": case "RelayEnvironmentLinkProofInvalidError": return new ConnectionBlockedError({ reason: "permission", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentEndpointTimedOutError": return new ConnectionTransientError({ reason: "timeout", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentEndpointUnavailableError": case "RelayEnvironmentLinkUnavailableError": return new ConnectionTransientError({ reason: "endpoint-unavailable", - message: error.message, + detail: error.message, traceId: error.traceId, }); case "RelayEnvironmentLinkFailedError": case "RelayInternalError": return new ConnectionTransientError({ reason: "relay-unavailable", - message: error.message, + detail: error.message, traceId: error.traceId, }); } @@ -80,13 +80,13 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { return new ConnectionTransientError({ reason: "timeout", - message: error.message, + detail: error.message, ...(error.traceId ? { traceId: error.traceId } : {}), }); } return new ConnectionTransientError({ reason: "relay-unavailable", - message: error.message, + detail: error.message, ...(error.traceId ? { traceId: error.traceId } : {}), }); } @@ -98,43 +98,43 @@ export function mapRemoteEnvironmentError( case "EnvironmentAuthInvalidError": return new ConnectionBlockedError({ reason: "authentication", - message: "The environment credential is invalid.", + detail: "The environment credential is invalid.", traceId: error.traceId, }); case "EnvironmentScopeRequiredError": case "EnvironmentOperationForbiddenError": return new ConnectionBlockedError({ reason: "permission", - message: "The environment credential does not grant the required access.", + detail: "The environment credential does not grant the required access.", traceId: error.traceId, }); case "EnvironmentRequestInvalidError": return new ConnectionBlockedError({ reason: "configuration", - message: "The environment rejected the authentication request.", + detail: "The environment rejected the authentication request.", traceId: error.traceId, }); case "RemoteEnvironmentAuthTimeoutError": return new ConnectionTransientError({ reason: "timeout", - message: error.message, + detail: error.message, }); case "RemoteEnvironmentAuthFetchError": return new ConnectionTransientError({ reason: "network", - message: error.message, + detail: error.message, }); case "EnvironmentInternalError": return new ConnectionTransientError({ reason: "remote-unavailable", - message: "The environment could not authorize the connection.", + detail: "The environment could not authorize the connection.", traceId: error.traceId, }); case "RemoteEnvironmentAuthInvalidJsonError": case "RemoteEnvironmentAuthUndeclaredStatusError": return new ConnectionTransientError({ reason: "remote-unavailable", - message: error.message, + detail: error.message, }); } } diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index eb1db447bff..53a041bbf30 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -1,12 +1,33 @@ export * from "./catalog.ts"; -export * from "./connectivity.ts"; -export * from "./driver.ts"; +export * as Connectivity from "./connectivity.ts"; +export * as CredentialStore from "./credentialStore.ts"; +export { + ConnectionDriver, + type ConnectionDriverProgress, + type EnvironmentConnectionLease, +} from "./driver.ts"; export * from "./errors.ts"; -export * from "./layer.ts"; +export * as Connection from "./layer.ts"; export * from "./model.ts"; -export * from "./onboarding.ts"; +export { + type BearerConnectionUpdateInput, + ConnectionOnboarding, + type PairingConnectionInput, + type SshConnectionInput, + prepareBearerConnectionUpdate, + preparePairingRegistration, + prepareSshRegistration, + registerPairingConnection, + registerSshConnection, + updateBearerConnection, +} from "./onboarding.ts"; export * from "./presentation.ts"; -export * from "./registry.ts"; -export * from "./resolver.ts"; -export * from "./supervisor.ts"; -export * from "./wakeups.ts"; +export * as ProfileStore from "./profileStore.ts"; +export { + EnvironmentNotRegisteredError, + EnvironmentRegistry, + PlatformEnvironmentRemovalError, +} from "./registry.ts"; +export { ConnectionResolver } from "./resolver.ts"; +export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts"; +export * as Wakeups from "./wakeups.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index c485c6c1b2c..a7485878e2d 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -2,37 +2,37 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; -import { connectionResolverLayer } from "./resolver.ts"; -import { connectionDriverLayer } from "./driver.ts"; -import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts"; -import { connectionOnboardingLayer } from "./onboarding.ts"; -import { PlatformConnectionSource } from "../platform/source.ts"; -import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts"; -import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts"; -import { rpcSessionFactoryLayer } from "../rpc/session.ts"; - -const resolverLayer = connectionResolverLayer.pipe( - Layer.provide(remoteEnvironmentAuthorizationLayer), +import * as ConnectionResolver from "./resolver.ts"; +import * as ConnectionDriver from "./driver.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as ConnectionOnboarding from "./onboarding.ts"; +import * as PlatformConnectionSource from "../platform/source.ts"; +import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import * as RpcSession from "../rpc/session.ts"; + +const resolverLayer = ConnectionResolver.layer.pipe( + Layer.provide(RemoteEnvironmentAuthorization.layer), ); -const driverLayer = connectionDriverLayer.pipe( - Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)), +const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), ); -const registryLayer = environmentRegistryLayer.pipe(Layer.provide(driverLayer)); +const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); -const onboardingLayer = connectionOnboardingLayer.pipe(Layer.provide(registryLayer)); +const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); const connectionServicesLayer = Layer.mergeAll( registryLayer, - relayEnvironmentDiscoveryLayer, + RelayEnvironmentDiscovery.layer, onboardingLayer, ); const connectionStartupLayer = Layer.effectDiscard( Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; - const platformSource = yield* PlatformConnectionSource; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; yield* registry.start; yield* platformSource.registrations.pipe( Stream.runForEach(registry.registerPlatform), @@ -41,6 +41,4 @@ const connectionStartupLayer = Layer.effectDiscard( }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), ); -export const connectionLayer = connectionStartupLayer.pipe( - Layer.provideMerge(connectionServicesLayer), -); +export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index 5c1daf090e4..fbcb302ed13 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -57,44 +57,49 @@ export type ConnectionTargetKind = ConnectionTarget["_tag"]; export type NetworkStatus = "unknown" | "offline" | "online"; -export type ConnectionTransientReason = - | "network" - | "timeout" - | "transport" - | "endpoint-unavailable" - | "relay-unavailable" - | "remote-unavailable"; - -export type ConnectionBlockedReason = - | "authentication" - | "configuration" - | "permission" - | "unsupported"; +export const ConnectionTransientReason = Schema.Literals([ + "network", + "timeout", + "transport", + "endpoint-unavailable", + "relay-unavailable", + "remote-unavailable", +]); +export type ConnectionTransientReason = typeof ConnectionTransientReason.Type; + +export const ConnectionBlockedReason = Schema.Literals([ + "authentication", + "configuration", + "permission", + "unsupported", +]); +export type ConnectionBlockedReason = typeof ConnectionBlockedReason.Type; export class ConnectionTransientError extends Schema.TaggedErrorClass()( "ConnectionTransientError", { - reason: Schema.Literals([ - "network", - "timeout", - "transport", - "endpoint-unavailable", - "relay-unavailable", - "remote-unavailable", - ]), - message: Schema.String, + reason: ConnectionTransientReason, + detail: Schema.String, traceId: Schema.optionalKey(Schema.String), }, -) {} +) { + override get message(): string { + return this.detail; + } +} export class ConnectionBlockedError extends Schema.TaggedErrorClass()( "ConnectionBlockedError", { - reason: Schema.Literals(["authentication", "configuration", "permission", "unsupported"]), - message: Schema.String, + reason: ConnectionBlockedReason, + detail: Schema.String, traceId: Schema.optionalKey(Schema.String), }, -) {} +) { + override get message(): string { + return this.detail; + } +} export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 14f71b5859b..e76bcd50a2c 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -6,22 +6,22 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { HttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; -import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; import { BearerConnectionCredential, BearerConnectionProfile, BearerConnectionRegistration, type ConnectionCatalogEntry, type ConnectionCredential, - ConnectionCredentialStore, SshConnectionProfile, SshConnectionRegistration, } from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; import { mapRemoteEnvironmentError } from "./errors.ts"; import { BearerConnectionTarget, @@ -29,8 +29,8 @@ import { SshConnectionTarget, type ConnectionAttemptError, } from "./model.ts"; -import type { ConnectionPersistenceError } from "../platform/persistence.ts"; -import { EnvironmentRegistry } from "./registry.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as EnvironmentRegistry from "./registry.ts"; export interface PairingConnectionInput { readonly pairingUrl?: string; @@ -54,13 +54,19 @@ export class ConnectionOnboarding extends Context.Service< { readonly registerPairing: ( input: PairingConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; readonly registerSsh: ( input: SshConnectionInput, - ) => Effect.Effect; + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; readonly updateBearer: ( input: BearerConnectionUpdateInput, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} @@ -71,7 +77,7 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - message: cause instanceof Error ? cause.message : "The pairing details are invalid.", + detail: cause instanceof Error ? cause.message : "The pairing details are invalid.", }), }); }, @@ -81,7 +87,7 @@ export const preparePairingRegistration = Effect.fn( "clientRuntime.connection.onboarding.preparePairingRegistration", )(function* (input: PairingConnectionInput) { const target = yield* resolvePairingTarget(input); - const presentation = yield* ClientPresentation; + const presentation = yield* ClientCapabilities.ClientPresentation; const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: target.httpBaseUrl, }).pipe(Effect.mapError(mapRemoteEnvironmentError)); @@ -116,7 +122,7 @@ export const registerPairingConnection = Effect.fn( "clientRuntime.connection.onboarding.registerPairingConnection", )(function* (input: PairingConnectionInput) { const registration = yield* preparePairingRegistration(input); - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register(registration); return registration.target.environmentId; }); @@ -127,8 +133,8 @@ const isBearerProfile = Schema.is(BearerConnectionProfile); export const updateBearerConnection = Effect.fn( "clientRuntime.connection.onboarding.updateBearerConnection", )(function* (input: BearerConnectionUpdateInput) { - const registry = yield* EnvironmentRegistry; - const credentials = yield* ConnectionCredentialStore; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); const credential = entry?.target._tag === "BearerConnectionTarget" @@ -159,7 +165,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( ) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: "Only saved bearer environments can be edited.", + detail: "Only saved bearer environments can be edited.", }); } @@ -167,7 +173,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( if (Option.isNone(credential) || !isBearerCredential(credential.value)) { return yield* new ConnectionBlockedError({ reason: "authentication", - message: "The saved bearer credential is unavailable.", + detail: "The saved bearer credential is unavailable.", }); } @@ -175,7 +181,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( if (label === "") { return yield* new ConnectionBlockedError({ reason: "configuration", - message: "Environment label cannot be empty.", + detail: "Environment label cannot be empty.", }); } const httpBaseUrl = yield* Effect.try({ @@ -183,7 +189,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( catch: (cause) => new ConnectionBlockedError({ reason: "configuration", - message: cause instanceof Error ? cause.message : "The environment URL is invalid.", + detail: cause instanceof Error ? cause.message : "The environment URL is invalid.", }), }); const connectionId = entry.target.connectionId; @@ -207,7 +213,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( export const prepareSshRegistration = Effect.fn( "clientRuntime.connection.onboarding.prepareSshRegistration", )(function* (input: SshConnectionInput) { - const gateway = yield* SshEnvironmentGateway; + const gateway = yield* ClientCapabilities.SshEnvironmentGateway; const provisioned = yield* gateway.provision(input.target); const connectionId = `ssh:${provisioned.environmentId}`; const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias; @@ -231,37 +237,36 @@ export const registerSshConnection = Effect.fn( "clientRuntime.connection.onboarding.registerSshConnection", )(function* (input: SshConnectionInput) { const registration = yield* prepareSshRegistration(input); - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register(registration); return registration.target.environmentId; }); -export const connectionOnboardingLayer = Layer.effect( - ConnectionOnboarding, - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; - const presentation = yield* ClientPresentation; - const httpClient = yield* HttpClient.HttpClient; - const ssh = yield* SshEnvironmentGateway; - const credentials = yield* ConnectionCredentialStore; +export const make = Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const presentation = yield* ClientCapabilities.ClientPresentation; + const httpClient = yield* HttpClient.HttpClient; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; - return ConnectionOnboarding.of({ - registerPairing: (input) => - registerPairingConnection(input).pipe( - Effect.provideService(EnvironmentRegistry, registry), - Effect.provideService(ClientPresentation, presentation), - Effect.provideService(HttpClient.HttpClient, httpClient), - ), - registerSsh: (input) => - registerSshConnection(input).pipe( - Effect.provideService(EnvironmentRegistry, registry), - Effect.provideService(SshEnvironmentGateway, ssh), - ), - updateBearer: (input) => - updateBearerConnection(input).pipe( - Effect.provideService(EnvironmentRegistry, registry), - Effect.provideService(ConnectionCredentialStore, credentials), - ), - }); - }), -); + return ConnectionOnboarding.of({ + registerPairing: (input) => + registerPairingConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.ClientPresentation, presentation), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + registerSsh: (input) => + registerSshConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.SshEnvironmentGateway, ssh), + ), + updateBearer: (input) => + updateBearerConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials), + ), + }); +}); + +export const layer = Layer.effect(ConnectionOnboarding, make); diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index d28dd65c18a..354b003a2d4 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -67,7 +67,7 @@ describe("connection presentation", () => { attempt: 2, lastFailure: new ConnectionTransientError({ reason: "transport", - message: "Socket closed.", + detail: "Socket closed.", traceId: "trace-previous", }), }), @@ -85,7 +85,7 @@ describe("connection presentation", () => { retryAt: 1, lastFailure: new ConnectionTransientError({ reason: "transport", - message: "Disconnected.", + detail: "Disconnected.", traceId: "trace-1", }), }), @@ -106,7 +106,7 @@ describe("connection presentation", () => { attempt: 2, lastFailure: new ConnectionTransientError({ reason: "transport", - message: "Relay connection timed out.", + detail: "Relay connection timed out.", traceId: "trace-retry", }), }), diff --git a/packages/client-runtime/src/connection/profileStore.ts b/packages/client-runtime/src/connection/profileStore.ts new file mode 100644 index 00000000000..3432a7fe16e --- /dev/null +++ b/packages/client-runtime/src/connection/profileStore.ts @@ -0,0 +1,24 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Option from "effect/Option"; + +import type { ConnectionProfile } from "./catalog.ts"; +import type { ConnectionAttemptError } from "./model.ts"; + +export class ConnectionProfileStore extends Context.Service< + ConnectionProfileStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (profile: ConnectionProfile) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/profileStore/ConnectionProfileStore") {} + +export const make = (service: ConnectionProfileStore["Service"]) => + ConnectionProfileStore.of(service); + +export const layer = (service: ConnectionProfileStore["Service"]) => + Layer.succeed(ConnectionProfileStore, make(service)); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index f0efe9b0549..885ba4cb781 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -14,23 +14,22 @@ import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { SshEnvironmentGateway } from "../platform/capabilities.ts"; -import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; import { BearerConnectionCredential, BearerConnectionProfile, BearerConnectionRegistration, type ConnectionRegistration, - ConnectionCredentialStore, - ConnectionProfileStore, PrimaryConnectionRegistration, RelayConnectionRegistration, SshConnectionProfile, type ConnectionCredential, type ConnectionProfile, } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; -import { ConnectionDriver } from "./driver.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; +import * as ConnectionDriver from "./driver.ts"; import { ConnectionTransientError, BearerConnectionTarget, @@ -41,17 +40,12 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "./model.ts"; -import { - ConnectionPersistenceError, - ConnectionRegistrationStore, - ConnectionTargetStore, - EnvironmentCacheStore, - EnvironmentOwnedDataCleanup, -} from "../platform/persistence.ts"; -import { EnvironmentRegistry, environmentRegistryLayer } from "./registry.ts"; -import type { RpcSession } from "../rpc/session.ts"; -import { EnvironmentSupervisor } from "./supervisor.ts"; -import { ConnectionWakeups } from "./wakeups.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; +import * as EnvironmentRegistry from "./registry.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -137,10 +131,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect; readonly beforeRegistrationRegister?: ( registration: ConnectionRegistration, - ) => Effect.Effect; + ) => Effect.Effect; readonly beforeRegistrationRemove?: ( target: ConnectionTarget, - ) => Effect.Effect; + ) => Effect.Effect; }, ) { const storedTargets = yield* Ref.make( @@ -160,7 +154,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( new Map([ [ SSH_CONNECTION.environmentId, - new RemoteDpopAccessToken({ + new TokenStore.RemoteDpopAccessToken({ environmentId: SSH_CONNECTION.environmentId, label: SSH_CONNECTION.label, endpoint: { @@ -177,10 +171,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ); const disconnectedSshTargets = yield* Ref.make>([]); - const targetStore = ConnectionTargetStore.of({ + const targetStore = Persistence.ConnectionTargetStore.of({ list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), }); - const registrationStore = ConnectionRegistrationStore.of({ + const registrationStore = Persistence.ConnectionRegistrationStore.of({ register: (registration) => Effect.gen(function* () { yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; @@ -239,7 +233,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }); }), }); - const cacheStore = EnvironmentCacheStore.of({ + const cacheStore = Persistence.EnvironmentCacheStore.of({ loadShell: (environmentId) => Ref.get(shellCache).pipe( Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))), @@ -264,16 +258,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ), ), }); - const ownedDataCleanup = EnvironmentOwnedDataCleanup.of({ + const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({ clear: (environmentId) => Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), }); const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online"); - const connectivity = Connectivity.of({ + const connectivity = Connectivity.Connectivity.of({ status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ get: (connectionId) => Ref.update(profileReadCount, (count) => count + 1).pipe( Effect.andThen(Ref.get(storedProfiles)), @@ -292,7 +286,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return next; }), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ get: (connectionId) => Ref.get(storedCredentials).pipe( Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), @@ -310,7 +304,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return next; }), }); - const tokenStore = RemoteDpopAccessTokenStore.of({ + const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ get: (environmentId) => Ref.get(storedRemoteTokens).pipe( Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), @@ -328,12 +322,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return next; }), }); - const sshGateway = SshEnvironmentGateway.of({ + const sshGateway = ClientCapabilities.SshEnvironmentGateway.of({ provision: () => Effect.die(new Error("SSH provisioning is not used.")), prepare: () => Effect.die(new Error("SSH preparation is not used.")), disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), }); - const driver = ConnectionDriver.of({ + const driver = ConnectionDriver.ConnectionDriver.of({ connect: (entry, reportProgress) => Effect.gen(function* () { const target = entry.target; @@ -350,12 +344,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( yield* Ref.update(sessions, (current) => [...current, { closed }]); const session = yield* Effect.acquireRelease( Effect.succeed({ - client: {} as RpcSession["client"], + client: {} as RpcSession.RpcSession["client"], initialConfig: Effect.die(new Error("Config is not used by registry tests.")), ready: Effect.void, probe: Effect.void, closed: Deferred.await(closed), - } satisfies RpcSession), + } satisfies RpcSession.RpcSession), () => Ref.update(releasedSessions, (count) => count + 1), ); yield* reportProgress({ stage: "synchronizing", prepared }); @@ -364,21 +358,24 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }), }); - const cacheLayer = Layer.succeed(EnvironmentCacheStore, cacheStore); - const layer = environmentRegistryLayer.pipe( + const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore); + const layer = EnvironmentRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ConnectionTargetStore, targetStore), - Layer.succeed(ConnectionRegistrationStore, registrationStore), - Layer.succeed(ConnectionProfileStore, profileStore), - Layer.succeed(ConnectionCredentialStore, credentialStore), - Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), - Layer.succeed(SshEnvironmentGateway, sshGateway), - Layer.succeed(Connectivity, connectivity), - Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), - Layer.succeed(ConnectionDriver, driver), + Layer.succeed(Persistence.ConnectionTargetStore, targetStore), + Layer.succeed(Persistence.ConnectionRegistrationStore, registrationStore), + Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), + Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(ClientCapabilities.SshEnvironmentGateway, sshGateway), + Layer.succeed(Connectivity.Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), + ), + Layer.succeed(ConnectionDriver.ConnectionDriver, driver), cacheLayer, - Layer.succeed(EnvironmentOwnedDataCleanup, ownedDataCleanup), + Layer.succeed(Persistence.EnvironmentOwnedDataCleanup, ownedDataCleanup), ), ), ); @@ -401,7 +398,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }); function awaitConnectionState( - registry: EnvironmentRegistry["Service"], + registry: EnvironmentRegistry.EnvironmentRegistry["Service"], environmentId: EnvironmentId, predicate: (state: SupervisorConnectionState) => boolean, ) { @@ -422,7 +419,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const entry = (yield* SubscriptionRef.get(registry.entries)).get( SSH_CONNECTION.environmentId, ); @@ -438,7 +435,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const offline = yield* Effect.forkChild( SubscriptionRef.changes(registry.networkStatus).pipe( Stream.filter((status) => status === "offline"), @@ -471,7 +468,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const start = yield* Effect.forkChild(registry.start); yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second")); @@ -487,7 +484,7 @@ describe("EnvironmentRegistry", () => { Effect.gen(function* () { const harness = yield* makeHarness([TARGET]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -499,7 +496,7 @@ describe("EnvironmentRegistry", () => { .runStream( TARGET.environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => Stream.concat( Stream.fromEffect(SubscriptionRef.get(supervisor.state)), @@ -527,7 +524,7 @@ describe("EnvironmentRegistry", () => { Effect.gen(function* () { const harness = yield* makeHarness([TARGET]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -554,7 +551,7 @@ describe("EnvironmentRegistry", () => { active!.closed, new ConnectionTransientError({ reason: "transport", - message: "Disconnected.", + detail: "Disconnected.", }), ); yield* Fiber.join(retryFiber); @@ -578,7 +575,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); yield* awaitConnectionState( registry, @@ -603,7 +600,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([RELAY_TARGET]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const firstObserved = yield* Deferred.make(); const secondObserved = yield* Deferred.make(); const labels = yield* Ref.make>([]); @@ -619,7 +616,7 @@ describe("EnvironmentRegistry", () => { .followStream( RELAY_TARGET.environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => Stream.concat(Stream.succeed(supervisor.target.label), Stream.never), ), @@ -655,7 +652,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.retryNow(EnvironmentId.make("removed-environment")); }).pipe(Effect.provide(harness.layer), Effect.scoped); }), @@ -670,7 +667,7 @@ describe("EnvironmentRegistry", () => { ); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.removeRelayEnvironments(); const targets = yield* Ref.get(harness.storedTargets); @@ -695,7 +692,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([RELAY_TARGET], [], [], { beforeRegistrationRemove: () => Effect.fail( - new ConnectionPersistenceError({ + new Persistence.ConnectionPersistenceError({ operation: "remove-connection", message: "Storage is unavailable.", }), @@ -703,7 +700,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -730,7 +727,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.register( new BearerConnectionRegistration({ target: BEARER_TARGET, @@ -760,7 +757,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); yield* awaitConnectionState( registry, @@ -791,7 +788,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([shadowedTarget]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); expect( @@ -825,7 +822,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const persistedRegistration = yield* registry .register(new RelayConnectionRegistration({ target: shadowedTarget })) .pipe(Effect.forkChild({ startImmediately: true })); @@ -864,7 +861,7 @@ describe("EnvironmentRegistry", () => { }); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* awaitConnectionState( registry, @@ -894,7 +891,7 @@ describe("EnvironmentRegistry", () => { const harness = yield* makeHarness([]); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const registration = new PrimaryConnectionRegistration({ target: TARGET }); yield* registry.registerPlatform(registration); yield* awaitConnectionState( @@ -924,7 +921,7 @@ describe("EnvironmentRegistry", () => { ); yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; yield* registry.start; yield* registry.remove(SSH_CONNECTION.environmentId); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 7560d06f50f..3a95185d835 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -12,120 +12,124 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; import { type ConnectionCatalogEntry, type ConnectionRegistration, - ConnectionProfileStore, type PrimaryConnectionRegistration, SshConnectionProfile, connectionRegistrationCatalogEntry, } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; +import * as Connectivity from "./connectivity.ts"; import type { ConnectionAttemptError, ConnectionTarget, NetworkStatus, SupervisorConnectionState, } from "./model.ts"; -import { - type ConnectionPersistenceError, - ConnectionRegistrationStore, - ConnectionTargetStore, - EnvironmentCacheStore, - EnvironmentOwnedDataCleanup, -} from "../platform/persistence.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, - makeEnvironmentSupervisor, -} from "./supervisor.ts"; -import { ConnectionDriver } from "./driver.ts"; -import { ConnectionWakeups } from "./wakeups.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const isSshConnectionProfile = Schema.is(SshConnectionProfile); export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass()( "EnvironmentNotRegisteredError", { - environmentId: Schema.String, - message: Schema.String, + environmentId: EnvironmentId, }, -) {} +) { + override get message(): string { + return `Environment ${this.environmentId} is not registered.`; + } +} export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass()( "PlatformEnvironmentRemovalError", { - environmentId: Schema.String, - message: Schema.String, - }, -) {} - -export interface EnvironmentRegistryService { - readonly entries: SubscriptionRef.SubscriptionRef< - ReadonlyMap - >; - readonly networkStatus: SubscriptionRef.SubscriptionRef; - readonly start: Effect.Effect; - readonly register: ( - registration: ConnectionRegistration, - ) => Effect.Effect; - readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; - readonly remove: ( environmentId: EnvironmentId, - ) => Effect.Effect< - void, - | ConnectionPersistenceError - | ConnectionAttemptError - | EnvironmentNotRegisteredError - | PlatformEnvironmentRemovalError - >; - readonly removeRelayEnvironments: () => Effect.Effect< - void, - ConnectionPersistenceError | ConnectionAttemptError | PlatformEnvironmentRemovalError - >; - readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; - readonly state: ( - environmentId: EnvironmentId, - ) => Effect.Effect; - readonly stateChanges: ( - environmentId: EnvironmentId, - ) => Stream.Stream; - readonly run: ( - environmentId: EnvironmentId, - effect: Effect.Effect, - ) => Effect.Effect>; - readonly runStream: ( - environmentId: EnvironmentId, - stream: Stream.Stream, - ) => Stream.Stream>; - readonly followStream: ( - environmentId: EnvironmentId, - stream: Stream.Stream, - ) => Stream.Stream>; + }, +) { + override get message(): string { + return `Platform-managed environment ${this.environmentId} cannot be removed.`; + } } export class EnvironmentRegistry extends Context.Service< EnvironmentRegistry, - EnvironmentRegistryService + { + readonly entries: SubscriptionRef.SubscriptionRef< + ReadonlyMap + >; + readonly networkStatus: SubscriptionRef.SubscriptionRef; + readonly start: Effect.Effect; + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; + readonly remove: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | PlatformEnvironmentRemovalError + >; + readonly removeRelayEnvironments: () => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | PlatformEnvironmentRemovalError + >; + readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; + readonly state: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly stateChanges: ( + environmentId: EnvironmentId, + ) => Stream.Stream; + readonly run: ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ) => Effect.Effect< + A, + E | EnvironmentNotRegisteredError, + Exclude + >; + readonly runStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream< + A, + E | EnvironmentNotRegisteredError, + Exclude + >; + readonly followStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream>; + } >()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {} interface EnvironmentServiceScope { readonly entry: ConnectionCatalogEntry; - readonly supervisor: EnvironmentSupervisorService; + readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; readonly scope: Scope.Closeable; } -const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* () { - const storage = yield* ConnectionTargetStore; - const registrations = yield* ConnectionRegistrationStore; - const cache = yield* EnvironmentCacheStore; - const ownedDataCleanup = yield* EnvironmentOwnedDataCleanup; - const profiles = yield* ConnectionProfileStore; - const connectivity = yield* Connectivity; - const driver = yield* ConnectionDriver; - const wakeups = yield* ConnectionWakeups; - const ssh = yield* SshEnvironmentGateway; +export const make = Effect.gen(function* () { + const storage = yield* Persistence.ConnectionTargetStore; + const registrations = yield* Persistence.ConnectionRegistrationStore; + const cache = yield* Persistence.EnvironmentCacheStore; + const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const connectivity = yield* Connectivity.Connectivity; + const driver = yield* ConnectionDriver.ConnectionDriver; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const persistedTargets = yield* storage.list; const initialEntries = new Map( yield* Effect.forEach( @@ -215,7 +219,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* if (entry === undefined) { return yield* new EnvironmentNotRegisteredError({ environmentId, - message: `Environment ${environmentId} is not registered.`, }); } return entry; @@ -241,12 +244,12 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* Effect.gen(function* () { const environmentId = entry.target.environmentId; const scope = yield* Scope.make(); - const supervisor = yield* makeEnvironmentSupervisor(entry, { + const supervisor = yield* EnvironmentSupervisor.make(entry, { initiallyDesired: false, }).pipe( - Effect.provideService(Connectivity, connectivity), - Effect.provideService(ConnectionDriver, driver), - Effect.provideService(ConnectionWakeups, wakeups), + Effect.provideService(Connectivity.Connectivity, connectivity), + Effect.provideService(ConnectionDriver.ConnectionDriver, driver), + Effect.provideService(ConnectionWakeups.ConnectionWakeups, wakeups), Scope.provide(scope), Effect.onError(() => Scope.close(scope, Exit.void)), ); @@ -280,28 +283,30 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* ); }); - const run: EnvironmentRegistryService["run"] = Effect.fn("EnvironmentRegistry.run")(function* < - A, - E, - R, - >(environmentId: EnvironmentId, effect: Effect.Effect) { - const supervisor = yield* acquireSupervisor(environmentId); - return yield* Effect.provideService(effect, EnvironmentSupervisor, supervisor); - }); + const run: EnvironmentRegistry["Service"]["run"] = Effect.fn("EnvironmentRegistry.run")( + function* (environmentId: EnvironmentId, effect: Effect.Effect) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ); + }, + ); - const runStream: EnvironmentRegistryService["runStream"] = ( + const runStream: EnvironmentRegistry["Service"]["runStream"] = ( environmentId: EnvironmentId, stream: Stream.Stream, ) => Stream.unwrap( acquireSupervisor(environmentId).pipe( Effect.map((supervisor) => - Stream.provideService(stream, EnvironmentSupervisor, supervisor), + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), ), ), ); - const followStream: EnvironmentRegistryService["followStream"] = ( + const followStream: EnvironmentRegistry["Service"]["followStream"] = ( environmentId: EnvironmentId, stream: Stream.Stream, ) => @@ -320,7 +325,11 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* Effect.match({ onFailure: () => Stream.empty, onSuccess: (supervisor) => - Stream.provideService(stream, EnvironmentSupervisor, supervisor), + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ), }), ), ), @@ -443,7 +452,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { return yield* new PlatformEnvironmentRemovalError({ environmentId, - message: "Platform-managed environments cannot be removed.", }); } const target = (yield* getEntry(environmentId)).target; @@ -532,7 +540,7 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* followStream( environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), ), ), @@ -570,7 +578,4 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* }); }); -export const environmentRegistryLayer = Layer.effect( - EnvironmentRegistry, - makeEnvironmentRegistry(), -); +export const layer = Layer.effect(EnvironmentRegistry, make); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 5c1ed83ec6b..7d165b22ea2 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -8,30 +8,19 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Tracer from "effect/Tracer"; -import { - ManagedRelayClient, - ManagedRelayClientError, - ManagedRelayRequestTimeoutError, -} from "../relay/managedRelay.ts"; -import { ConnectionResolver } from "./resolver.ts"; -import { connectionResolverLayer } from "./resolver.ts"; -import { - CloudSession, - PrimaryEnvironmentAuth, - RelayDeviceIdentity, - SshEnvironmentGateway, -} from "../platform/capabilities.ts"; -import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ConnectionResolver from "./resolver.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import { BearerConnectionCredential, BearerConnectionProfile, type ConnectionCatalogEntry, - ConnectionCredentialStore, - ConnectionProfileStore, SshConnectionProfile, type ConnectionCredential, type ConnectionProfile, } from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; import { BearerConnectionTarget, ConnectionTransientError, @@ -40,6 +29,7 @@ import { SshConnectionTarget, type ConnectionTarget, } from "./model.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const ENDPOINT = { @@ -79,8 +69,10 @@ function collectingTracer(spans: Array): Tracer.Tracer { }); } -function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectEnvironment"]) { - return ManagedRelayClient.of({ +function relayClient( + connectEnvironment: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"], +) { + return ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => unsupported("listEnvironments"), listDevices: () => unsupported("listDevices"), @@ -99,29 +91,29 @@ function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectE const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { readonly profiles?: ReadonlyArray; readonly credentials?: ReadonlyArray; - readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"]; - readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; - readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"]; + readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; + readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; readonly primaryBearerToken?: string; - readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"]; + readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; }) => { const profiles = new Map( (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), ); const credentials = new Map(options?.credentials ?? []); - const profileStore = ConnectionProfileStore.of({ + const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))), put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)), remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)), }); - const credentialStore = ConnectionCredentialStore.of({ + const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))), put: (connectionId, credential) => Effect.sync(() => void credentials.set(connectionId, credential)), remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)), }); - const remote = RemoteEnvironmentAuthorization.of({ + const remote = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({ authorizeBearer: options?.authorizeBearer ?? ((input) => @@ -151,7 +143,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }), )), }); - const ssh = SshEnvironmentGateway.of({ + const ssh = ClientCapabilities.SshEnvironmentGateway.of({ provision: () => Effect.die("unused"), prepare: options?.prepareSsh ?? @@ -169,23 +161,28 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o }); const dependencies = Layer.mergeAll( - Layer.succeed(ConnectionProfileStore, profileStore), - Layer.succeed(ConnectionCredentialStore, credentialStore), - Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })), + Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), Layer.succeed( - PrimaryEnvironmentAuth, - PrimaryEnvironmentAuth.of({ + ClientCapabilities.CloudSession, + ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }), + ), + Layer.succeed( + ClientCapabilities.PrimaryEnvironmentAuth, + ClientCapabilities.PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), }), ), Layer.succeed( - RelayDeviceIdentity, - RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }), + ClientCapabilities.RelayDeviceIdentity, + ClientCapabilities.RelayDeviceIdentity.of({ + deviceId: Effect.succeed(Option.some("device-1")), + }), ), - Layer.succeed(RemoteEnvironmentAuthorization, remote), - Layer.succeed(SshEnvironmentGateway, ssh), + Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), + Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), Layer.succeed( - ManagedRelayClient, + ManagedRelay.ManagedRelayClient, relayClient( options?.connectEnvironment ?? ((input) => @@ -199,14 +196,14 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o ), ); - return Effect.succeed(connectionResolverLayer.pipe(Layer.provide(dependencies))); + return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies))); }); describe("ConnectionResolver", () => { it.effect("prepares a primary environment without remote capabilities", () => Effect.gen(function* () { const brokerLayer = yield* makeDependencies(); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); const target = new PrimaryConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Primary", @@ -244,7 +241,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); const target = new PrimaryConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Primary", @@ -292,7 +289,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); expect( (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, @@ -349,7 +346,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); expect(yield* Ref.get(relayInputs)).toEqual([ @@ -387,7 +384,7 @@ describe("ConnectionResolver", () => { Effect.withSpan("test.remote.authorizeDpop"), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); yield* broker .prepare(catalogEntry(target)) @@ -431,7 +428,7 @@ describe("ConnectionResolver", () => { }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); expect( (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, @@ -449,15 +446,15 @@ describe("ConnectionResolver", () => { const brokerLayer = yield* makeDependencies({ connectEnvironment: () => Effect.fail( - new ManagedRelayClientError({ + new ManagedRelay.ManagedRelayClientError({ message: "Relay timed out.", - cause: new ManagedRelayRequestTimeoutError({ + cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ message: "Relay timed out.", }), }), ), }); - const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); expect(error).toBeInstanceOf(ConnectionTransientError); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index ae18535e4d0..c219bde092c 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -6,22 +6,16 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; -import { ManagedRelayClient } from "../relay/managedRelay.ts"; -import { - CloudSession, - PrimaryEnvironmentAuth, - RelayDeviceIdentity, - SshEnvironmentGateway, -} from "../platform/capabilities.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; import { BearerConnectionCredential, BearerConnectionProfile, type ConnectionCatalogEntry, - ConnectionCredentialStore, - ConnectionProfileStore, SshConnectionProfile, } from "./catalog.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; import { credentialMissingError, environmentMismatchError, @@ -37,6 +31,7 @@ import type { SshConnectionTarget, } from "./model.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; +import * as ConnectionProfileStore from "./profileStore.ts"; export class ConnectionResolver extends Context.Service< ConnectionResolver, @@ -60,8 +55,8 @@ function primarySocketUrl(target: PrimaryConnectionTarget): string { } const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { - const auth = yield* PrimaryEnvironmentAuth; - const remote = yield* RemoteEnvironmentAuthorization; + const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fn("clientRuntime.connection.broker.primary")(function* ( target: PrimaryConnectionTarget, @@ -92,8 +87,8 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary }); const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { - const credentials = yield* ConnectionCredentialStore; - const remote = yield* RemoteEnvironmentAuthorization; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, @@ -106,7 +101,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") if (!isBearerProfile(profile)) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: `Connection profile ${target.connectionId} is not a bearer connection.`, + detail: `Connection profile ${target.connectionId} is not a bearer connection.`, }); } if (profile.environmentId !== target.environmentId) { @@ -144,10 +139,10 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") }); const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { - const relay = yield* ManagedRelayClient; - const session = yield* CloudSession; - const identity = yield* RelayDeviceIdentity; - const remote = yield* RemoteEnvironmentAuthorization; + const relay = yield* ManagedRelay.ManagedRelayClient; + const session = yield* ClientCapabilities.CloudSession; + const identity = yield* ClientCapabilities.RelayDeviceIdentity; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fnUntraced( function* (target: RelayConnectionTarget) { @@ -192,9 +187,9 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f }); const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { - const profiles = yield* ConnectionProfileStore; - const ssh = yield* SshEnvironmentGateway; - const remote = yield* RemoteEnvironmentAuthorization; + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fn("clientRuntime.connection.broker.ssh")(function* ( entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, @@ -207,7 +202,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct if (!isSshProfile(profile)) { return yield* new ConnectionBlockedError({ reason: "configuration", - message: `Connection profile ${target.connectionId} is not an SSH connection.`, + detail: `Connection profile ${target.connectionId} is not an SSH connection.`, }); } if (profile.environmentId !== target.environmentId) { @@ -246,34 +241,33 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct }); }); -export const connectionResolverLayer = Layer.effect( - ConnectionResolver, - Effect.gen(function* () { - const primary = yield* makePrimaryBroker(); - const bearer = yield* makeBearerBroker(); - const relay = yield* makeRelayBroker(); - const ssh = yield* makeSshBroker(); +export const make = Effect.gen(function* () { + const primary = yield* makePrimaryBroker(); + const bearer = yield* makeBearerBroker(); + const relay = yield* makeRelayBroker(); + const ssh = yield* makeSshBroker(); - const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( - entry: ConnectionCatalogEntry, - ) { - const target: ConnectionTarget = entry.target; - yield* Effect.annotateCurrentSpan({ - "connection.environment.id": target.environmentId, - "connection.target.kind": target._tag, - }); - switch (target._tag) { - case "PrimaryConnectionTarget": - return yield* primary(target); - case "BearerConnectionTarget": - return yield* bearer({ ...entry, target }); - case "RelayConnectionTarget": - return yield* relay(target); - case "SshConnectionTarget": - return yield* ssh({ ...entry, target }); - } + const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + entry: ConnectionCatalogEntry, + ) { + const target: ConnectionTarget = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, }); + switch (target._tag) { + case "PrimaryConnectionTarget": + return yield* primary(target); + case "BearerConnectionTarget": + return yield* bearer({ ...entry, target }); + case "RelayConnectionTarget": + return yield* relay(target); + case "SshConnectionTarget": + return yield* ssh({ ...entry, target }); + } + }); + + return ConnectionResolver.of({ prepare }); +}); - return ConnectionResolver.of({ prepare }); - }), -); +export const layer = Layer.effect(ConnectionResolver, make); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 1ebd2812c92..eadeceacc2c 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -13,12 +13,8 @@ import * as Tracer from "effect/Tracer"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; -import { - ConnectionDriver, - type ConnectionDriverProgress, - type EnvironmentConnectionLease, -} from "./driver.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionDriver from "./driver.ts"; import { ConnectionBlockedError, ConnectionTransientError, @@ -30,9 +26,9 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "./model.ts"; -import type { RpcSession } from "../rpc/session.ts"; -import { makeEnvironmentSupervisor } from "./supervisor.ts"; -import { ConnectionWakeups } from "./wakeups.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "./supervisor.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -70,14 +66,14 @@ const TEST_RPC_CLIENT = {} as WsRpcProtocolClient; function transient(message = "Connection failed.") { return new ConnectionTransientError({ reason: "transport", - message, + detail: message, }); } function blocked(message = "Authentication required.") { return new ConnectionBlockedError({ reason: "authentication", - message, + detail: message, }); } @@ -137,7 +133,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ReadonlyArray> >([]); - const connectivity = Connectivity.of({ + const connectivity = Connectivity.Connectivity.of({ status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }); @@ -152,7 +148,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: const connect = Effect.fn("TestConnectionDriver.connect")(function* ( entry: ConnectionCatalogEntry, - reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect, ) { const target = entry.target; yield* reportProgress({ stage: "preparing" }); @@ -170,27 +166,30 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, closed: Deferred.await(closed), - } satisfies RpcSession), + } satisfies RpcSession.RpcSession), () => Ref.update(releaseCount, (count) => count + 1), ); yield* reportProgress({ stage: "synchronizing", prepared }); yield* session.ready; - return { prepared, session } satisfies EnvironmentConnectionLease; + return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease; }); const dependencies = Layer.mergeAll( - Layer.succeed(Connectivity, connectivity), + Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: SubscriptionRef.changes(wakeups).pipe( Stream.drop(1), Stream.map((event) => event.reason), ), }), ), - Layer.succeed(ConnectionDriver, ConnectionDriver.of({ connect })), + Layer.succeed( + ConnectionDriver.ConnectionDriver, + ConnectionDriver.ConnectionDriver.of({ connect }), + ), ); return { @@ -235,7 +234,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe( Effect.provide(harness.dependencies), @@ -263,7 +262,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not attempt a connection until it is desired", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( Effect.provide(harness.dependencies), ); @@ -275,7 +274,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not let the initial connect signal cancel the first attempt", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( Effect.provide(harness.dependencies), ); @@ -290,7 +289,7 @@ describe("EnvironmentSupervisor", () => { it.effect("waits while offline and connects immediately when the network returns", () => Effect.gen(function* () { const harness = yield* makeHarness({ networkStatus: "offline" }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -317,7 +316,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ prepare: () => Effect.fail(transient()), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -345,7 +344,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -378,7 +377,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ ready: () => Effect.never, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -410,7 +409,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ prepare: () => Effect.never, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -442,7 +441,7 @@ describe("EnvironmentSupervisor", () => { ? Effect.die(new Error("Native transport defect.")) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -470,7 +469,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -488,7 +487,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -505,7 +504,7 @@ describe("EnvironmentSupervisor", () => { it.effect("releases a live session while offline and starts a new generation when online", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -534,7 +533,7 @@ describe("EnvironmentSupervisor", () => { prepare: (attempt) => attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -553,7 +552,7 @@ describe("EnvironmentSupervisor", () => { prepare: () => Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -591,7 +590,7 @@ describe("EnvironmentSupervisor", () => { it.effect("treats an involuntary session close as transient and reconnects", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -617,7 +616,7 @@ describe("EnvironmentSupervisor", () => { it.effect("keeps escalating backoff when a newly opened session flaps", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -663,7 +662,7 @@ describe("EnvironmentSupervisor", () => { Effect.andThen(Deferred.succeed(probeCalled, undefined)), ), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -684,7 +683,7 @@ describe("EnvironmentSupervisor", () => { probe: (attempt) => attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -707,7 +706,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -732,7 +731,7 @@ describe("EnvironmentSupervisor", () => { const harness = yield* makeHarness({ probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), }); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -749,7 +748,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not churn a healthy session when credentials change", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -766,7 +765,7 @@ describe("EnvironmentSupervisor", () => { it.effect("releases and reconnects a relay session when credentials change", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -791,7 +790,7 @@ describe("EnvironmentSupervisor", () => { ? Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.succeed(PREPARED_CONNECTION), }); - const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -807,7 +806,7 @@ describe("EnvironmentSupervisor", () => { it.effect("explicit disconnect releases the session and returns to available", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); @@ -824,7 +823,7 @@ describe("EnvironmentSupervisor", () => { it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () => Effect.gen(function* () { const harness = yield* makeHarness(); - const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 56ebe0efaf4..99889916a9a 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -15,12 +15,8 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Tracer from "effect/Tracer"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import { Connectivity } from "./connectivity.ts"; -import { - ConnectionDriver, - type ConnectionDriverProgress, - type EnvironmentConnectionLease, -} from "./driver.ts"; +import * as Connectivity from "./connectivity.ts"; +import * as ConnectionDriver from "./driver.ts"; import { type ConnectionAttemptError, type ConnectionTarget, @@ -29,8 +25,8 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "./model.ts"; -import type { RpcSession } from "../rpc/session.ts"; -import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts"; +import * as RpcSession from "../rpc/session.ts"; +import * as ConnectionWakeups from "./wakeups.ts"; const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; @@ -47,7 +43,7 @@ type SupervisorSignal = | { readonly _tag: "DisconnectRequested" } | { readonly _tag: "RetryRequested" } | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } - | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeup }; + | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup }; interface PendingRetryTrace { readonly previousAttempt: Tracer.Span; @@ -80,7 +76,7 @@ type EstablishmentEvent = readonly exit: Exit.Exit< { readonly attemptSpan: Option.Option; - readonly lease: EnvironmentConnectionLease; + readonly lease: ConnectionDriver.EnvironmentConnectionLease; }, TracedAttemptFailure >; @@ -102,16 +98,6 @@ export interface EnvironmentSupervisorOptions { readonly initiallyDesired?: boolean; } -export interface EnvironmentSupervisorService { - readonly target: ConnectionTarget; - readonly state: SubscriptionRef.SubscriptionRef; - readonly session: SubscriptionRef.SubscriptionRef>; - readonly prepared: SubscriptionRef.SubscriptionRef>; - readonly connect: Effect.Effect; - readonly disconnect: Effect.Effect; - readonly retryNow: Effect.Effect; -} - function retryDelayMs(failureCount: number): number { return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; } @@ -199,7 +185,7 @@ function failureFromExit( failure: { error: new ConnectionTransientError({ reason: "transport", - message: `${target.label} connection failed unexpectedly.`, + detail: `${target.label} connection failed unexpectedly.`, }), attemptSpan: Option.none(), }, @@ -208,34 +194,34 @@ function failureFromExit( export class EnvironmentSupervisor extends Context.Service< EnvironmentSupervisor, - EnvironmentSupervisorService ->()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") { - static layer( - entry: ConnectionCatalogEntry, - options?: EnvironmentSupervisorOptions, - ): Layer.Layer< - EnvironmentSupervisor, - never, - Connectivity | ConnectionDriver | ConnectionWakeups - > { - return Layer.effect(EnvironmentSupervisor, makeEnvironmentSupervisor(entry, options)); + { + readonly target: ConnectionTarget; + readonly state: SubscriptionRef.SubscriptionRef; + readonly session: SubscriptionRef.SubscriptionRef>; + readonly prepared: SubscriptionRef.SubscriptionRef>; + readonly connect: Effect.Effect; + readonly disconnect: Effect.Effect; + readonly retryNow: Effect.Effect; } -} +>()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {} -export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")(function* ( +export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( entry: ConnectionCatalogEntry, options?: EnvironmentSupervisorOptions, ): Effect.fn.Return< - EnvironmentSupervisorService, + EnvironmentSupervisor["Service"], never, - Connectivity | ConnectionDriver | Scope.Scope | ConnectionWakeups + | Connectivity.Connectivity + | ConnectionDriver.ConnectionDriver + | Scope.Scope + | ConnectionWakeups.ConnectionWakeups > { const target = entry.target; yield* annotateTarget(target); - const connectivity = yield* Connectivity; - const driver = yield* ConnectionDriver; - const wakeups = yield* ConnectionWakeups; + const connectivity = yield* Connectivity.Connectivity; + const driver = yield* ConnectionDriver.ConnectionDriver; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; const initialIntent: SupervisorIntent = { desired: options?.initiallyDesired ?? false, network: yield* connectivity.status, @@ -249,7 +235,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") ? offlineState(initialIntent, 0, 0, null) : connectingState(initialIntent, 0, 1, null), ); - const session = yield* SubscriptionRef.make>(Option.none()); + const session = yield* SubscriptionRef.make>(Option.none()); const prepared = yield* SubscriptionRef.make>(Option.none()); const clearLease = Effect.all( @@ -280,7 +266,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") attempt: number, generation: number, lastFailure: ConnectionAttemptError | null, - progress: ConnectionDriverProgress, + progress: ConnectionDriver.ConnectionDriverProgress, ) { if ("prepared" in progress) { yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); @@ -301,7 +287,11 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") }); const traceRelayEstablishment = ( - effect: Effect.Effect, + effect: Effect.Effect< + ConnectionDriver.EnvironmentConnectionLease, + ConnectionAttemptError, + Scope.Scope + >, attempt: number, generation: number, pendingRetry: Option.Option, @@ -392,7 +382,9 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") } }); - const monitorConnectedLease = Effect.fnUntraced(function* (lease: EnvironmentConnectionLease) { + const monitorConnectedLease = Effect.fnUntraced(function* ( + lease: ConnectionDriver.EnvironmentConnectionLease, + ) { for (;;) { const next = yield* Queue.take(signals); switch (next._tag) { @@ -417,7 +409,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") Effect.fail( new ConnectionTransientError({ reason: "timeout", - message: `${target.label} did not respond to a connection health check.`, + detail: `${target.label} did not respond to a connection health check.`, }), ), }), @@ -499,7 +491,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") failure: { error: new ConnectionTransientError({ reason: "timeout", - message: `${target.label} did not respond during connection setup.`, + detail: `${target.label} did not respond during connection setup.`, }), attemptSpan: Option.none(), }, @@ -722,3 +714,14 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") retryNow, }); }); + +export const layer = ( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, +): Layer.Layer< + EnvironmentSupervisor, + never, + | Connectivity.Connectivity + | ConnectionDriver.ConnectionDriver + | ConnectionWakeups.ConnectionWakeups +> => Layer.effect(EnvironmentSupervisor, make(entry, options)); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 93449077838..107c5983e02 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; import type * as Stream from "effect/Stream"; export type ConnectionWakeup = "application-active" | "credentials-changed"; @@ -9,3 +10,8 @@ export class ConnectionWakeups extends Context.Service< readonly changes: Stream.Stream; } >()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} + +export const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); + +export const layer = (service: ConnectionWakeups["Service"]) => + Layer.succeed(ConnectionWakeups, make(service)); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index e7e59dd85d4..5cc3f0c1a86 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -18,11 +18,8 @@ import { PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; @@ -51,14 +48,14 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct return { sequence: dispatched.length }; }), } as unknown as WsRpcProtocolClient; - const session: RpcSession = { + const session: RpcSession.RpcSession = { client, initialConfig: Effect.never, ready: Effect.void, probe: Effect.void, closed: Effect.never, }; - return EnvironmentSupervisor.of({ + return EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), session: yield* SubscriptionRef.make(Option.some(session)), @@ -66,7 +63,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void, - } satisfies EnvironmentSupervisorService); + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); }); describe("environment commands", () => { @@ -80,7 +77,7 @@ describe("environment commands", () => { title: "Project", workspaceRoot: "/workspace/project", createdAt: "2026-06-06T00:00:00.000Z", - }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); expect(result).toEqual({ sequence: 1 }); expect(dispatched).toEqual([ @@ -105,7 +102,7 @@ describe("environment commands", () => { commandId: CommandId.make("queued-command"), threadId: ThreadId.make("thread-1"), createdAt: "2026-06-06T00:01:00.000Z", - }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); expect(dispatched).toEqual([ { @@ -126,7 +123,7 @@ describe("environment commands", () => { yield* archiveThread({ commandId: CommandId.make("archive-command"), threadId: ThreadId.make("thread-1"), - }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); expect(dispatched).toEqual([ { diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index 359594033f5..8ad6b81e12b 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; import { BearerConnectionCredential, BearerConnectionProfile, @@ -38,7 +38,7 @@ const BEARER_PROFILE = new BearerConnectionProfile({ const BEARER_CREDENTIAL = new BearerConnectionCredential({ token: "bearer-token", }); -const REMOTE_TOKEN = new RemoteDpopAccessToken({ +const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ environmentId: ENVIRONMENT_ID, label: "Remote", endpoint: { diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 4eafb298e5e..0ba55dfa2fb 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -6,7 +6,7 @@ import { ConnectionProfile, } from "../connection/catalog.ts"; import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; -import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; +import * as TokenStore from "../authorization/tokenStore.ts"; export const StoredConnectionCredential = Schema.Struct({ connectionId: Schema.String, @@ -19,7 +19,7 @@ export const ConnectionCatalogDocument = Schema.Struct({ targets: Schema.Array(PersistedConnectionTarget), profiles: Schema.Array(ConnectionProfile), credentials: Schema.Array(StoredConnectionCredential), - remoteDpopTokens: Schema.Array(RemoteDpopAccessToken), + remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), }); export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index c1703657162..e05302195db 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -13,17 +13,12 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { - ManagedRelayClient, - ManagedRelayClientError, - ManagedRelayRequestTimeoutError, - type ManagedRelayClientShape, -} from "./managedRelay.ts"; -import { CloudSession } from "../platform/capabilities.ts"; -import { Connectivity } from "../connection/connectivity.ts"; +import * as ManagedRelay from "./managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as Connectivity from "../connection/connectivity.ts"; import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts"; -import { ConnectionWakeups } from "../connection/wakeups.ts"; -import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; +import * as RelayEnvironmentDiscovery from "./discovery.ts"; const environments = [ { @@ -63,7 +58,7 @@ function status( const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { const networkStatus = yield* SubscriptionRef.make("online"); const listCalls = yield* Ref.make(0); - const listFailure = yield* Ref.make(null); + const listFailure = yield* Ref.make(null); const secondListCall = yield* Deferred.make(); const clerkToken = yield* Ref.make("clerk-token"); const wakeups = yield* SubscriptionRef.make<{ @@ -74,10 +69,16 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { reason: "application-active", }); const statusRequests = yield* Ref.make( - new Map>(), + new Map< + string, + Deferred.Deferred + >(), ); for (const environment of environments) { - const request = yield* Deferred.make(); + const request = yield* Deferred.make< + RelayEnvironmentStatusResponse, + ManagedRelay.ManagedRelayClientError + >(); yield* Ref.update(statusRequests, (current) => { const next = new Map(current); next.set(environment.environmentId, request); @@ -85,7 +86,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { }); } - const client = ManagedRelayClient.of({ + const client = ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.gen(function* () { @@ -112,25 +113,25 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), resetTokenCache: Effect.void, - } satisfies ManagedRelayClientShape); - const connectivity = Connectivity.of({ + } satisfies ManagedRelay.ManagedRelayClient["Service"]); + const connectivity = Connectivity.Connectivity.of({ status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }); - const layer = relayEnvironmentDiscoveryLayer.pipe( + const layer = RelayEnvironmentDiscovery.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ManagedRelayClient, client), + Layer.succeed(ManagedRelay.ManagedRelayClient, client), Layer.succeed( - CloudSession, - CloudSession.of({ + ClientCapabilities.CloudSession, + ClientCapabilities.CloudSession.of({ clerkToken: Ref.get(clerkToken).pipe( Effect.flatMap((token) => token === null ? Effect.fail( new ConnectionBlockedError({ reason: "authentication", - message: "Signed out.", + detail: "Signed out.", }), ) : Effect.succeed(token), @@ -138,10 +139,10 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { ), }), ), - Layer.succeed(Connectivity, connectivity), + Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( - ConnectionWakeups, - ConnectionWakeups.of({ + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: SubscriptionRef.changes(wakeups).pipe( Stream.drop(1), Stream.map((event) => event.reason), @@ -173,7 +174,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const refreshFiber = yield* Effect.forkChild(discovery.refresh); const checking = yield* SubscriptionRef.changes(discovery.state).pipe( @@ -224,7 +225,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const requests = yield* Ref.get(harness.statusRequests); for (const environment of environments) { yield* Deferred.succeed( @@ -253,13 +254,13 @@ describe("RelayEnvironmentDiscovery", () => { it.effect("publishes listing failures without rejecting the refresh command", () => Effect.gen(function* () { const networkStatus = yield* SubscriptionRef.make("online"); - const client = ManagedRelayClient.of({ + const client = ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.fail( - new ManagedRelayClientError({ + new ManagedRelay.ManagedRelayClientError({ message: "Relay environment listing timed out.", - cause: new ManagedRelayRequestTimeoutError({ + cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ message: "Relay environment listing timed out.", }), }), @@ -274,25 +275,28 @@ describe("RelayEnvironmentDiscovery", () => { unregisterDevice: () => Effect.die("unused"), registerLiveActivity: () => Effect.die("unused"), resetTokenCache: Effect.void, - } satisfies ManagedRelayClientShape); - const layer = relayEnvironmentDiscoveryLayer.pipe( + } satisfies ManagedRelay.ManagedRelayClient["Service"]); + const layer = RelayEnvironmentDiscovery.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(ManagedRelayClient, client), - Layer.succeed(CloudSession, { + Layer.succeed(ManagedRelay.ManagedRelayClient, client), + Layer.succeed(ClientCapabilities.CloudSession, { clerkToken: Effect.succeed("clerk-token"), }), - Layer.succeed(Connectivity, { + Layer.succeed(Connectivity.Connectivity, { status: SubscriptionRef.get(networkStatus), changes: SubscriptionRef.changes(networkStatus), }), - Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), + Layer.succeed( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), + ), ), ), ); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; yield* discovery.refresh; const state = yield* SubscriptionRef.get(discovery.state); @@ -310,7 +314,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const requests = yield* Ref.get(harness.statusRequests); for (const environment of environments) { yield* Deferred.succeed( @@ -323,7 +327,7 @@ describe("RelayEnvironmentDiscovery", () => { yield* Ref.set( harness.listFailure, - new ManagedRelayClientError({ + new ManagedRelay.ManagedRelayClientError({ message: "Relay environment listing failed.", }), ); @@ -340,7 +344,7 @@ describe("RelayEnvironmentDiscovery", () => { Effect.gen(function* () { const harness = yield* makeHarness(); yield* Effect.gen(function* () { - const discovery = yield* RelayEnvironmentDiscovery; + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; const refreshFiber = yield* Effect.forkChild(discovery.refresh); yield* SubscriptionRef.changes(discovery.state).pipe( Stream.filter((state) => state.environments.size === environments.length), diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index c763aef9f68..8cbadea1ca5 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -16,12 +16,12 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { ManagedRelayClient } from "./managedRelay.ts"; -import { CloudSession } from "../platform/capabilities.ts"; -import { Connectivity } from "../connection/connectivity.ts"; +import * as ManagedRelay from "./managedRelay.ts"; +import * as ClientCapabilities from "../platform/capabilities.ts"; +import * as Connectivity from "../connection/connectivity.ts"; import { mapManagedRelayError } from "../connection/errors.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; -import { ConnectionWakeups } from "../connection/wakeups.ts"; +import * as ConnectionWakeups from "../connection/wakeups.ts"; export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error"; @@ -39,14 +39,12 @@ export interface RelayEnvironmentDiscoveryState { readonly error: Option.Option; } -export interface RelayEnvironmentDiscoveryService { - readonly state: SubscriptionRef.SubscriptionRef; - readonly refresh: Effect.Effect; -} - export class RelayEnvironmentDiscovery extends Context.Service< RelayEnvironmentDiscovery, - RelayEnvironmentDiscoveryService + { + readonly state: SubscriptionRef.SubscriptionRef; + readonly refresh: Effect.Effect; + } >()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {} export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { @@ -64,7 +62,7 @@ function validateStatus( return Effect.fail( new ConnectionBlockedError({ reason: "configuration", - message: "Relay returned status for a different environment.", + detail: "Relay returned status for a different environment.", }), ); } @@ -76,7 +74,7 @@ function validateStatus( return Effect.fail( new ConnectionBlockedError({ reason: "configuration", - message: "Relay returned status for a different environment endpoint.", + detail: "Relay returned status for a different environment endpoint.", }), ); } @@ -87,7 +85,7 @@ function validateStatus( return Effect.fail( new ConnectionBlockedError({ reason: "configuration", - message: "Relay returned a descriptor for a different environment.", + detail: "Relay returned a descriptor for a different environment.", }), ); } @@ -104,11 +102,11 @@ function relayAccountId(clerkToken: string): Option.Option { } } -const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { - const relay = yield* ManagedRelayClient; - const session = yield* CloudSession; - const connectivity = yield* Connectivity; - const wakeups = yield* ConnectionWakeups; +export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { + const relay = yield* ManagedRelay.ManagedRelayClient; + const session = yield* ClientCapabilities.CloudSession; + const connectivity = yield* Connectivity.Connectivity; + const wakeups = yield* ConnectionWakeups.ConnectionWakeups; const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); const refreshLock = yield* Semaphore.make(1); const hasRefreshed = yield* Ref.make(false); @@ -327,7 +325,4 @@ const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make" return RelayEnvironmentDiscovery.of({ state, refresh }); }); -export const relayEnvironmentDiscoveryLayer = Layer.effect( - RelayEnvironmentDiscovery, - makeRelayEnvironmentDiscovery(), -); +export const layer = Layer.effect(RelayEnvironmentDiscovery, make()); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index dff78cefae5..507d137cacc 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -22,11 +22,8 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; @@ -46,7 +43,7 @@ const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { stage: "downloading", }; -function session(client: WsRpcProtocolClient): RpcSession { +function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, @@ -58,10 +55,12 @@ function session(client: WsRpcProtocolClient): RpcSession { const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { const state = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); - const activeSession = yield* SubscriptionRef.make>(Option.none()); + const activeSession = yield* SubscriptionRef.make>( + Option.none(), + ); const prepared = yield* SubscriptionRef.make>(Option.none()); const retryCount = yield* Ref.make(0); - const supervisor = EnvironmentSupervisor.of({ + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state, session: activeSession, @@ -69,7 +68,7 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { connect: Effect.void, disconnect: Effect.void, retryNow: Ref.update(retryCount, (count) => count + 1), - } satisfies EnvironmentSupervisorService); + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); return { activeSession, retryCount, @@ -89,7 +88,7 @@ describe("environment RPC", () => { yield* SubscriptionRef.set(activeSession, Option.some(session(client))); const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService( EnvironmentRpcRequestObserver, EnvironmentRpcRequestObserver.of({ @@ -128,7 +127,7 @@ describe("environment RPC", () => { const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( Stream.take(2), Stream.runCollect, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); yield* Effect.yieldNow; @@ -172,7 +171,7 @@ describe("environment RPC", () => { const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); @@ -212,7 +211,7 @@ describe("environment RPC", () => { const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); @@ -243,7 +242,7 @@ describe("environment RPC", () => { yield* SubscriptionRef.set(activeSession, Option.some(session(client))); const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.flip, ); @@ -283,7 +282,7 @@ describe("environment RPC", () => { }, ).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); for (let attempt = 0; attempt < 100 && observedFailures.length < 1; attempt += 1) { @@ -329,7 +328,7 @@ describe("environment RPC", () => { }, ).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.forkChild, ); for (let attempt = 0; attempt < 100; attempt += 1) { @@ -377,7 +376,7 @@ describe("environment RPC", () => { }, ).pipe( Stream.runDrain, - Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.exit, ); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index 8dec2c2b2b4..76608388f0a 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,4 +1,4 @@ export * from "./client.ts"; export * from "./http.ts"; export * from "./protocol.ts"; -export * from "./session.ts"; +export { type RpcSession, RpcSessionFactory } from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 0317806f9b3..7820c93a935 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -18,7 +18,7 @@ import { PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; -import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts"; +import * as RpcSession from "./session.ts"; type SocketEventType = "open" | "message" | "close" | "error"; type SocketEvent = { @@ -149,8 +149,8 @@ const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = rpcSessionFactoryLayer.pipe(Layer.provide(constructorLayer)); - const factory = yield* RpcSessionFactory.pipe(Effect.provide(layer)); + const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 2c97b75f829..f9594c1b7ca 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -5,7 +5,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; @@ -47,98 +48,97 @@ function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptErro case "EnvironmentAuthorizationError": return new ConnectionBlockedError({ reason: "permission", - message: error.message, + detail: error.message, }); case "KeybindingsConfigParseError": case "ServerSettingsError": return new ConnectionTransientErrorClass({ reason: "remote-unavailable", - message: error.message, + detail: error.message, }); case "RpcClientError": return new ConnectionTransientErrorClass({ reason: "transport", - message: error.message, + detail: error.message, }); } } -export const rpcSessionFactoryLayer = Layer.effect( - RpcSessionFactory, - Effect.gen(function* () { - const webSocketConstructor = yield* Socket.WebSocketConstructor; +export const make = Effect.gen(function* () { + const webSocketConstructor = yield* Socket.WebSocketConstructor; - const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { - yield* Effect.annotateCurrentSpan({ - "connection.environment.id": connection.environmentId, - }); + const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": connection.environmentId, + }); - const connected = yield* Deferred.make(); - const disconnected = yield* Deferred.make(); - const hooks = RpcClient.ConnectionHooks.of({ - onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), - onDisconnect: Deferred.isDone(connected).pipe( - Effect.flatMap((wasConnected) => - Deferred.fail( - disconnected, - new ConnectionTransientErrorClass({ - reason: "transport", - message: wasConnected - ? `${connection.label} disconnected.` - : `${connection.label} could not establish a WebSocket connection.`, - }), - ), - ), - Effect.asVoid, - ), - }); - const socketLayer = Socket.layerWebSocket(connection.socketUrl, { - openTimeout: SOCKET_OPEN_TIMEOUT, - }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); - const protocolLayer = Layer.effect( - RpcClient.Protocol, - RpcClient.makeProtocolSocket({ - retryTransientErrors: false, - retryPolicy: Schedule.recurs(0), - }), - ).pipe( - Layer.provide( - Layer.mergeAll( - socketLayer, - RpcSerialization.layerJson, - Layer.succeed(RpcClient.ConnectionHooks, hooks), + const connected = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + const hooks = RpcClient.ConnectionHooks.of({ + onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), + onDisconnect: Deferred.isDone(connected).pipe( + Effect.flatMap((wasConnected) => + Deferred.fail( + disconnected, + new ConnectionTransientErrorClass({ + reason: "transport", + detail: wasConnected + ? `${connection.label} disconnected.` + : `${connection.label} could not establish a WebSocket connection.`, + }), ), ), - ); - const protocolContext = yield* Layer.build(protocolLayer).pipe( - Effect.withSpan("environment.websocket.connect"), - ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( - Effect.mapError(mapInitialConfigError), - Effect.withSpan("environment.initialSync"), + Effect.asVoid, + ), + }); + const socketLayer = Socket.layerWebSocket(connection.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); + const protocolLayer = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), + }), + ).pipe( + Layer.provide( + Layer.mergeAll( + socketLayer, + RpcSerialization.layerJson, + Layer.succeed(RpcClient.ConnectionHooks, hooks), ), - ); - const probe = client[WS_METHODS.serverGetConfig]({}).pipe( + ), + ); + const protocolContext = yield* Layer.build(protocolLayer).pipe( + Effect.withSpan("environment.websocket.connect"), + ); + const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfig = yield* Effect.cached( + client[WS_METHODS.serverGetConfig]({}).pipe( Effect.mapError(mapInitialConfigError), + Effect.withSpan("environment.initialSync"), + ), + ); + const probe = client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.asVoid, + Effect.withSpan("clientRuntime.connection.rpcSession.probe"), + ); + + return { + client, + initialConfig, + ready: Deferred.await(connected).pipe( + Effect.andThen(initialConfig), Effect.asVoid, - Effect.withSpan("clientRuntime.connection.rpcSession.probe"), - ); + Effect.raceFirst(Deferred.await(disconnected)), + ), + probe, + closed: Deferred.await(disconnected), + } satisfies RpcSession; + }); - return { - client, - initialConfig, - ready: Deferred.await(connected).pipe( - Effect.andThen(initialConfig), - Effect.asVoid, - Effect.raceFirst(Deferred.await(disconnected)), - ), - probe, - closed: Deferred.await(disconnected), - } satisfies RpcSession; - }); + return RpcSessionFactory.of({ connect }); +}); - return RpcSessionFactory.of({ connect }); - }), -); +export const layer = Layer.effect(RpcSessionFactory, make); diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 6f406b409a2..6dfa5001a48 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -5,10 +5,10 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; -import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { createAtomCommandScheduler, createRuntimeCommand, @@ -26,13 +26,13 @@ export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.f }); export function createEnvironmentCatalogAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const commandScheduler = createAtomCommandScheduler(); const serial = { mode: "serial" as const, key: () => "environment-catalog" }; const catalogAtom = runtime.atom( Stream.unwrap( - EnvironmentRegistry.pipe( + EnvironmentRegistry.EnvironmentRegistry.pipe( Effect.map((registry) => SubscriptionRef.changes(registry.entries).pipe( Stream.map((entries) => ({ @@ -52,7 +52,7 @@ export function createEnvironmentCatalogAtoms( const networkStatusAtom = runtime.atom( Stream.unwrap( - EnvironmentRegistry.pipe( + EnvironmentRegistry.EnvironmentRegistry.pipe( Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)), ), ), @@ -68,7 +68,7 @@ export function createEnvironmentCatalogAtoms( followStreamInEnvironment( environmentId, Stream.unwrap( - EnvironmentSupervisor.pipe( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), ), ), @@ -81,29 +81,39 @@ export function createEnvironmentCatalogAtoms( label: "environment-catalog:register", scheduler: commandScheduler, concurrency: serial, - execute: (target: Parameters[0]) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.register(target))), + execute: ( + target: Parameters[0], + ) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.register(target)), + ), }); const remove = createRuntimeCommand(runtime, { label: "environment-catalog:remove", scheduler: commandScheduler, concurrency: serial, execute: (environmentId: EnvironmentIdType) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.remove(environmentId))), + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.remove(environmentId)), + ), }); const removeRelayEnvironments = createRuntimeCommand(runtime, { label: "environment-catalog:remove-relay-environments", scheduler: commandScheduler, concurrency: serial, execute: (_input: void) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.removeRelayEnvironments())), + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.removeRelayEnvironments()), + ), }); const retryNow = createRuntimeCommand(runtime, { label: "environment-catalog:retry-now", scheduler: commandScheduler, concurrency: serial, execute: (environmentId: EnvironmentIdType) => - EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.retryNow(environmentId))), + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.retryNow(environmentId)), + ), }); return { diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 5ed4d504ce3..2eab7214225 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -16,12 +16,9 @@ import { PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { makeEnvironmentShellState } from "./shell.ts"; @@ -39,7 +36,7 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { updatedAt: "2026-06-06T00:00:00.000Z", }; -function session(client: WsRpcProtocolClient): RpcSession { +function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, @@ -57,10 +54,10 @@ describe("environment shell synchronization", () => { [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); - const activeSession = yield* SubscriptionRef.make>( + const activeSession = yield* SubscriptionRef.make>( Option.some(session(client)), ); - const supervisor = EnvironmentSupervisor.of({ + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, session: activeSession, @@ -68,8 +65,8 @@ describe("environment shell synchronization", () => { connect: Effect.void, disconnect: Effect.void, retryNow: Effect.void, - } satisfies EnvironmentSupervisorService); - const cache = EnvironmentCacheStore.of({ + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ loadShell: () => Effect.succeed(Option.none()), saveShell: () => Effect.never, loadThread: () => Effect.succeed(Option.none()), @@ -78,8 +75,8 @@ describe("environment shell synchronization", () => { clear: () => Effect.void, }); const shellState = yield* makeEnvironmentShellState().pipe( - Effect.provideService(EnvironmentSupervisor, supervisor), - Effect.provideService(EnvironmentCacheStore, cache), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), ); yield* SubscriptionRef.set(supervisorState, { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index eef2550e2e2..3a5a8b69630 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -24,12 +24,9 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "../connection/model.ts"; -import { - EnvironmentSupervisor, - type EnvironmentSupervisorService, -} from "../connection/supervisor.ts"; -import { EnvironmentCacheStore } from "../platform/persistence.ts"; -import type { RpcSession } from "../rpc/session.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, makeEnvironmentThreadState, @@ -69,7 +66,7 @@ const BASE_THREAD: OrchestrationThread = { type TestThreadInput = OrchestrationThreadStreamItem | Error; -function testSession(client: WsRpcProtocolClient): RpcSession { +function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, @@ -117,11 +114,11 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), ), } as unknown as WsRpcProtocolClient; - const supervisorSession = yield* SubscriptionRef.make>( + const supervisorSession = yield* SubscriptionRef.make>( Option.some(testSession(client)), ); const prepared = yield* SubscriptionRef.make>(Option.none()); - const supervisor = EnvironmentSupervisor.of({ + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, session: supervisorSession, @@ -129,8 +126,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o connect: Effect.void, disconnect: Effect.void, retryNow: Ref.update(retryCount, (count) => count + 1), - } satisfies EnvironmentSupervisorService); - const cache = EnvironmentCacheStore.of({ + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ loadShell: () => Effect.succeed(Option.none()), saveShell: () => Effect.void, loadThread: (_environmentId, threadId) => @@ -146,8 +143,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o clear: () => Effect.void, }); const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( - Effect.provideService(EnvironmentSupervisor, supervisor), - Effect.provideService(EnvironmentCacheStore, cache), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), ); yield* SubscriptionRef.changes(threadState).pipe( Stream.runForEach((state) =>