| author | |
| committer | |
| log | 0ad1e9d9e4bf691ad1dcfb5af69de11b0b48047f |
| tree | bbb8a32a0b3913f32caba44b02b511340d8a429e |
| parent | 2f8d3baa88cd137b11083942a4961ca79a9f99be |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>44 files changed, 1281 insertions(+), 1186 deletions(-)
apps/mobile/src/connection/catalog-store.ts+1-1| ... | ... | @@ -18,7 +18,7 @@ export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; |
| 18 | 18 | function catalogError(operation: string, cause: unknown) { |
| 19 | 19 | return new ConnectionTransientError({ |
| 20 | 20 | reason: "remote-unavailable", |
| 21 | message: `Could not ${operation} the local connection catalog: ${String(cause)}`, | |
| 21 | detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, | |
| 22 | 22 | }); |
| 23 | 23 | } |
| 24 | 24 |
apps/mobile/src/connection/platform.ts+53-47| ... | ... | @@ -10,8 +10,8 @@ import { |
| 10 | 10 | import { |
| 11 | 11 | ConnectionBlockedError, |
| 12 | 12 | ConnectionTransientError, |
| 13 | ConnectionWakeups, | |
| 14 | 13 | Connectivity, |
| 14 | Wakeups, | |
| 15 | 15 | } from "@t3tools/client-runtime/connection"; |
| 16 | 16 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; |
| 17 | 17 | import { AuthStandardClientScopes } from "@t3tools/contracts"; |
| ... | ... | @@ -41,53 +41,47 @@ function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "on |
| 41 | 41 | return "unknown"; |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | const connectivityLayer = Layer.succeed( | |
| 45 | Connectivity, | |
| 46 | Connectivity.of({ | |
| 47 | status: Effect.tryPromise({ | |
| 48 | try: () => Network.getNetworkStateAsync(), | |
| 49 | catch: () => undefined, | |
| 50 | }).pipe( | |
| 51 | Effect.match({ | |
| 52 | onFailure: () => "unknown" as const, | |
| 53 | onSuccess: networkStatus, | |
| 54 | }), | |
| 55 | ), | |
| 56 | changes: Stream.callback((queue) => | |
| 44 | const connectivityLayer = Connectivity.layer({ | |
| 45 | status: Effect.tryPromise({ | |
| 46 | try: () => Network.getNetworkStateAsync(), | |
| 47 | catch: () => undefined, | |
| 48 | }).pipe( | |
| 49 | Effect.match({ | |
| 50 | onFailure: () => "unknown" as const, | |
| 51 | onSuccess: networkStatus, | |
| 52 | }), | |
| 53 | ), | |
| 54 | changes: Stream.callback((queue) => | |
| 55 | Effect.acquireRelease( | |
| 56 | Effect.sync(() => | |
| 57 | Network.addNetworkStateListener((state) => { | |
| 58 | Queue.offerUnsafe(queue, networkStatus(state)); | |
| 59 | }), | |
| 60 | ), | |
| 61 | (subscription) => Effect.sync(() => subscription.remove()), | |
| 62 | ).pipe(Effect.asVoid), | |
| 63 | ), | |
| 64 | }); | |
| 65 | ||
| 66 | const wakeupsLayer = Wakeups.layer({ | |
| 67 | changes: Stream.merge( | |
| 68 | Stream.callback<"application-active">((queue) => | |
| 57 | 69 | Effect.acquireRelease( |
| 58 | 70 | Effect.sync(() => |
| 59 | Network.addNetworkStateListener((state) => { | |
| 60 | Queue.offerUnsafe(queue, networkStatus(state)); | |
| 71 | AppState.addEventListener("change", (state) => { | |
| 72 | if (state === "active") { | |
| 73 | Queue.offerUnsafe(queue, "application-active"); | |
| 74 | } | |
| 61 | 75 | }), |
| 62 | 76 | ), |
| 63 | 77 | (subscription) => Effect.sync(() => subscription.remove()), |
| 64 | 78 | ).pipe(Effect.asVoid), |
| 65 | 79 | ), |
| 66 | }), | |
| 67 | ); | |
| 68 | ||
| 69 | const wakeupsLayer = Layer.succeed( | |
| 70 | ConnectionWakeups, | |
| 71 | ConnectionWakeups.of({ | |
| 72 | changes: Stream.merge( | |
| 73 | Stream.callback<"application-active">((queue) => | |
| 74 | Effect.acquireRelease( | |
| 75 | Effect.sync(() => | |
| 76 | AppState.addEventListener("change", (state) => { | |
| 77 | if (state === "active") { | |
| 78 | Queue.offerUnsafe(queue, "application-active"); | |
| 79 | } | |
| 80 | }), | |
| 81 | ), | |
| 82 | (subscription) => Effect.sync(() => subscription.remove()), | |
| 83 | ).pipe(Effect.asVoid), | |
| 84 | ), | |
| 85 | managedRelayAccountChanges(appAtomRegistry).pipe( | |
| 86 | Stream.map(() => "credentials-changed" as const), | |
| 87 | ), | |
| 80 | managedRelayAccountChanges(appAtomRegistry).pipe( | |
| 81 | Stream.map(() => "credentials-changed" as const), | |
| 88 | 82 | ), |
| 89 | }), | |
| 90 | ); | |
| 83 | ), | |
| 84 | }); | |
| 91 | 85 | |
| 92 | 86 | const capabilitiesLayer = Layer.succeedContext( |
| 93 | 87 | Context.make( |
| ... | ... | @@ -98,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( |
| 98 | 92 | if (session === null) { |
| 99 | 93 | return yield* new ConnectionBlockedError({ |
| 100 | 94 | reason: "authentication", |
| 101 | message: "Sign in to T3 Cloud to connect this environment.", | |
| 95 | detail: "Sign in to T3 Cloud to connect this environment.", | |
| 102 | 96 | }); |
| 103 | 97 | } |
| 104 | 98 | const token = yield* session.readClerkToken().pipe( |
| ... | ... | @@ -106,14 +100,14 @@ const capabilitiesLayer = Layer.succeedContext( |
| 106 | 100 | (error) => |
| 107 | 101 | new ConnectionTransientError({ |
| 108 | 102 | reason: "network", |
| 109 | message: error.message, | |
| 103 | detail: error.message, | |
| 110 | 104 | }), |
| 111 | 105 | ), |
| 112 | 106 | ); |
| 113 | 107 | if (token === null) { |
| 114 | 108 | return yield* new ConnectionBlockedError({ |
| 115 | 109 | reason: "authentication", |
| 116 | message: "The T3 Cloud session is unavailable.", | |
| 110 | detail: "The T3 Cloud session is unavailable.", | |
| 117 | 111 | }); |
| 118 | 112 | } |
| 119 | 113 | return token; |
| ... | ... | @@ -132,7 +126,7 @@ const capabilitiesLayer = Layer.succeedContext( |
| 132 | 126 | catch: (cause) => |
| 133 | 127 | new ConnectionTransientError({ |
| 134 | 128 | reason: "remote-unavailable", |
| 135 | message: `Could not load the mobile device identity: ${String(cause)}`, | |
| 129 | detail: `Could not load the mobile device identity: ${String(cause)}`, | |
| 136 | 130 | }), |
| 137 | 131 | }).pipe(Effect.map(Option.some)), |
| 138 | 132 | }), |
| ... | ... | @@ -151,14 +145,14 @@ const capabilitiesLayer = Layer.succeedContext( |
| 151 | 145 | Effect.fail( |
| 152 | 146 | new ConnectionBlockedError({ |
| 153 | 147 | reason: "unsupported", |
| 154 | message: "SSH environments are only available in the desktop app.", | |
| 148 | detail: "SSH environments are only available in the desktop app.", | |
| 155 | 149 | }), |
| 156 | 150 | ), |
| 157 | 151 | prepare: () => |
| 158 | 152 | Effect.fail( |
| 159 | 153 | new ConnectionBlockedError({ |
| 160 | 154 | reason: "unsupported", |
| 161 | message: "SSH environments are only available in the desktop app.", | |
| 155 | detail: "SSH environments are only available in the desktop app.", | |
| 162 | 156 | }), |
| 163 | 157 | ), |
| 164 | 158 | disconnect: () => Effect.void, |
| ... | ... | @@ -195,7 +189,19 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( |
| 195 | 189 | }), |
| 196 | 190 | ); |
| 197 | 191 | |
| 198 | export const connectionPlatformLayer = Layer.mergeAll( | |
| 192 | type ConnectionPlatformLayerSource = | |
| 193 | | typeof connectionStorageLayer | |
| 194 | | typeof connectivityLayer | |
| 195 | | typeof wakeupsLayer | |
| 196 | | typeof capabilitiesLayer | |
| 197 | | typeof platformConnectionSourceLayer | |
| 198 | | typeof environmentOwnedDataCleanupLayer; | |
| 199 | ||
| 200 | export const connectionPlatformLayer: Layer.Layer< | |
| 201 | Layer.Success<ConnectionPlatformLayerSource>, | |
| 202 | Layer.Error<ConnectionPlatformLayerSource>, | |
| 203 | Layer.Services<ConnectionPlatformLayerSource> | |
| 204 | > = Layer.mergeAll( | |
| 199 | 205 | connectionStorageLayer, |
| 200 | 206 | connectivityLayer, |
| 201 | 207 | wakeupsLayer, |
apps/mobile/src/connection/runtime.ts+18-6| ... | ... | @@ -1,16 +1,28 @@ |
| 1 | import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; | |
| 1 | import { Connection } from "@t3tools/client-runtime/connection"; | |
| 2 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| 5 | 5 | import { runtimeContextLayer } from "../lib/runtime"; |
| 6 | 6 | import { connectionPlatformLayer } from "./platform"; |
| 7 | 7 | |
| 8 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( | |
| 9 | Layer.provide(runtimeContextLayer), | |
| 10 | ); | |
| 8 | const providedConnectionPlatformLayer: Layer.Layer< | |
| 9 | Layer.Success<typeof connectionPlatformLayer>, | |
| 10 | Layer.Error<typeof connectionPlatformLayer> | |
| 11 | > = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); | |
| 12 | ||
| 13 | type ConnectionLayerSource = | |
| 14 | | typeof Connection.layer | |
| 15 | | typeof runtimeContextLayer | |
| 16 | | typeof providedConnectionPlatformLayer; | |
| 11 | 17 | |
| 12 | export const connectionLayer = clientConnectionLayer.pipe( | |
| 18 | export const connectionLayer: Layer.Layer< | |
| 19 | Layer.Success<ConnectionLayerSource>, | |
| 20 | Layer.Error<ConnectionLayerSource> | |
| 21 | > = Connection.layer.pipe( | |
| 13 | 22 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), |
| 14 | 23 | ); |
| 15 | 24 | |
| 16 | export const connectionAtomRuntime = Atom.runtime(connectionLayer); | |
| 25 | export const connectionAtomRuntime: Atom.AtomRuntime< | |
| 26 | Layer.Success<typeof connectionLayer>, | |
| 27 | Layer.Error<typeof connectionLayer> | |
| 28 | > = Atom.runtime(connectionLayer); |
apps/mobile/src/connection/storage.ts+10-10| ... | ... | @@ -8,11 +8,11 @@ import { |
| 8 | 8 | removeCatalogValue, |
| 9 | 9 | replaceCatalogValue, |
| 10 | 10 | } from "@t3tools/client-runtime/platform"; |
| 11 | import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; | |
| 11 | import { TokenStore } from "@t3tools/client-runtime/authorization"; | |
| 12 | 12 | import { |
| 13 | ConnectionCredentialStore, | |
| 14 | ConnectionProfileStore, | |
| 15 | 13 | ConnectionTransientError, |
| 14 | CredentialStore, | |
| 15 | ProfileStore, | |
| 16 | 16 | } from "@t3tools/client-runtime/connection"; |
| 17 | 17 | import { |
| 18 | 18 | EnvironmentId, |
| ... | ... | @@ -58,7 +58,7 @@ const LegacyStoredShellSnapshot = Schema.Struct({ |
| 58 | 58 | function catalogError(operation: string, cause: unknown) { |
| 59 | 59 | return new ConnectionTransientError({ |
| 60 | 60 | reason: "remote-unavailable", |
| 61 | message: `Could not ${operation} the local connection catalog: ${String(cause)}`, | |
| 61 | detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, | |
| 62 | 62 | }); |
| 63 | 63 | } |
| 64 | 64 | |
| ... | ... | @@ -197,7 +197,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 197 | 197 | .update((document) => removeConnectionFromCatalog(document, target)) |
| 198 | 198 | .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), |
| 199 | 199 | }); |
| 200 | const profileStore = ConnectionProfileStore.of({ | |
| 200 | const profileStore = ProfileStore.make({ | |
| 201 | 201 | get: (connectionId) => |
| 202 | 202 | catalog.read.pipe( |
| 203 | 203 | Effect.map((document) => |
| ... | ... | @@ -221,7 +221,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 221 | 221 | ), |
| 222 | 222 | })), |
| 223 | 223 | }); |
| 224 | const credentialStore = ConnectionCredentialStore.of({ | |
| 224 | const credentialStore = CredentialStore.make({ | |
| 225 | 225 | get: (connectionId) => |
| 226 | 226 | catalog.read.pipe( |
| 227 | 227 | Effect.map((document) => |
| ... | ... | @@ -248,7 +248,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 248 | 248 | ), |
| 249 | 249 | })), |
| 250 | 250 | }); |
| 251 | const remoteTokenStore = RemoteDpopAccessTokenStore.of({ | |
| 251 | const remoteTokenStore = TokenStore.make({ | |
| 252 | 252 | get: (environmentId) => |
| 253 | 253 | catalog.read.pipe( |
| 254 | 254 | Effect.map((document) => |
| ... | ... | @@ -423,9 +423,9 @@ export const connectionStorageLayer = Layer.effectContext( |
| 423 | 423 | |
| 424 | 424 | return Context.make(ConnectionTargetStore, targetStore).pipe( |
| 425 | 425 | Context.add(ConnectionRegistrationStore, registrationStore), |
| 426 | Context.add(ConnectionProfileStore, profileStore), | |
| 427 | Context.add(ConnectionCredentialStore, credentialStore), | |
| 428 | Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), | |
| 426 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), | |
| 427 | Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), | |
| 428 | Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), | |
| 429 | 429 | Context.add(EnvironmentCacheStore, cacheStore), |
| 430 | 430 | ); |
| 431 | 431 | }), |
apps/web/src/cloud/linkEnvironment.test.ts+2-4| ... | ... | @@ -15,9 +15,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 15 | 15 | import { afterEach, beforeEach, vi } from "vite-plus/test"; |
| 16 | 16 | import { |
| 17 | 17 | AVAILABLE_CONNECTION_STATE, |
| 18 | type EnvironmentRegistryService, | |
| 19 | 18 | EnvironmentSupervisor, |
| 20 | type EnvironmentSupervisorService, | |
| 21 | 19 | type PreparedConnection, |
| 22 | 20 | PrimaryConnectionTarget, |
| 23 | 21 | } from "@t3tools/client-runtime/connection"; |
| ... | ... | @@ -114,13 +112,13 @@ function registryLayer(options?: { |
| 114 | 112 | connect: Effect.void, |
| 115 | 113 | disconnect: Effect.void, |
| 116 | 114 | retryNow: Effect.void, |
| 117 | } satisfies EnvironmentSupervisorService); | |
| 115 | } satisfies EnvironmentSupervisor["Service"]); | |
| 118 | 116 | const registry = { |
| 119 | 117 | run: <A, E, R>(_environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) => |
| 120 | 118 | Effect.provideService(effect, EnvironmentSupervisor, supervisor), |
| 121 | 119 | runStream: <A, E, R>(_environmentId: EnvironmentId, stream: Stream.Stream<A, E, R>) => |
| 122 | 120 | Stream.provideService(stream, EnvironmentSupervisor, supervisor), |
| 123 | } as unknown as EnvironmentRegistryService; | |
| 121 | } as unknown as EnvironmentRegistry["Service"]; | |
| 124 | 122 | return EnvironmentRegistry.of(registry); |
| 125 | 123 | }), |
| 126 | 124 | ); |
apps/web/src/connection/platform.ts+63-56| ... | ... | @@ -10,11 +10,11 @@ import { |
| 10 | 10 | import { |
| 11 | 11 | ConnectionBlockedError, |
| 12 | 12 | ConnectionTransientError, |
| 13 | ConnectionWakeups, | |
| 14 | 13 | Connectivity, |
| 15 | 14 | mapRemoteEnvironmentError, |
| 16 | 15 | PrimaryConnectionRegistration, |
| 17 | 16 | PrimaryConnectionTarget, |
| 17 | Wakeups, | |
| 18 | 18 | } from "@t3tools/client-runtime/connection"; |
| 19 | 19 | import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; |
| 20 | 20 | import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; |
| ... | ... | @@ -50,56 +50,50 @@ function currentNetworkStatus(): "unknown" | "offline" | "online" { |
| 50 | 50 | return navigator.onLine ? "online" : "offline"; |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | const connectivityLayer = Layer.succeed( | |
| 54 | Connectivity, | |
| 55 | Connectivity.of({ | |
| 56 | status: Effect.sync(currentNetworkStatus), | |
| 57 | changes: Stream.callback((queue) => | |
| 53 | const connectivityLayer = Connectivity.layer({ | |
| 54 | status: Effect.sync(currentNetworkStatus), | |
| 55 | changes: Stream.callback((queue) => | |
| 56 | Effect.acquireRelease( | |
| 57 | Effect.sync(() => { | |
| 58 | const online = () => Queue.offerUnsafe(queue, "online"); | |
| 59 | const offline = () => Queue.offerUnsafe(queue, "offline"); | |
| 60 | window.addEventListener("online", online); | |
| 61 | window.addEventListener("offline", offline); | |
| 62 | return { online, offline }; | |
| 63 | }), | |
| 64 | ({ online, offline }) => | |
| 65 | Effect.sync(() => { | |
| 66 | window.removeEventListener("online", online); | |
| 67 | window.removeEventListener("offline", offline); | |
| 68 | }), | |
| 69 | ).pipe(Effect.asVoid), | |
| 70 | ), | |
| 71 | }); | |
| 72 | ||
| 73 | const wakeupsLayer = Wakeups.layer({ | |
| 74 | changes: Stream.merge( | |
| 75 | Stream.callback<"application-active">((queue) => | |
| 58 | 76 | Effect.acquireRelease( |
| 59 | 77 | Effect.sync(() => { |
| 60 | const online = () => Queue.offerUnsafe(queue, "online"); | |
| 61 | const offline = () => Queue.offerUnsafe(queue, "offline"); | |
| 62 | window.addEventListener("online", online); | |
| 63 | window.addEventListener("offline", offline); | |
| 64 | return { online, offline }; | |
| 78 | const listener = () => { | |
| 79 | if (document.visibilityState === "visible") { | |
| 80 | Queue.offerUnsafe(queue, "application-active"); | |
| 81 | } | |
| 82 | }; | |
| 83 | document.addEventListener("visibilitychange", listener); | |
| 84 | return listener; | |
| 65 | 85 | }), |
| 66 | ({ online, offline }) => | |
| 86 | (listener) => | |
| 67 | 87 | Effect.sync(() => { |
| 68 | window.removeEventListener("online", online); | |
| 69 | window.removeEventListener("offline", offline); | |
| 88 | document.removeEventListener("visibilitychange", listener); | |
| 70 | 89 | }), |
| 71 | 90 | ).pipe(Effect.asVoid), |
| 72 | 91 | ), |
| 73 | }), | |
| 74 | ); | |
| 75 | ||
| 76 | const wakeupsLayer = Layer.succeed( | |
| 77 | ConnectionWakeups, | |
| 78 | ConnectionWakeups.of({ | |
| 79 | changes: Stream.merge( | |
| 80 | Stream.callback<"application-active">((queue) => | |
| 81 | Effect.acquireRelease( | |
| 82 | Effect.sync(() => { | |
| 83 | const listener = () => { | |
| 84 | if (document.visibilityState === "visible") { | |
| 85 | Queue.offerUnsafe(queue, "application-active"); | |
| 86 | } | |
| 87 | }; | |
| 88 | document.addEventListener("visibilitychange", listener); | |
| 89 | return listener; | |
| 90 | }), | |
| 91 | (listener) => | |
| 92 | Effect.sync(() => { | |
| 93 | document.removeEventListener("visibilitychange", listener); | |
| 94 | }), | |
| 95 | ).pipe(Effect.asVoid), | |
| 96 | ), | |
| 97 | managedRelayAccountChanges(appAtomRegistry).pipe( | |
| 98 | Stream.map(() => "credentials-changed" as const), | |
| 99 | ), | |
| 92 | managedRelayAccountChanges(appAtomRegistry).pipe( | |
| 93 | Stream.map(() => "credentials-changed" as const), | |
| 100 | 94 | ), |
| 101 | }), | |
| 102 | ); | |
| 95 | ), | |
| 96 | }); | |
| 103 | 97 | |
| 104 | 98 | function clientMetadata() { |
| 105 | 99 | const desktop = window.desktopBridge !== undefined; |
| ... | ... | @@ -116,12 +110,12 @@ function sshPreparationError(cause: unknown) { |
| 116 | 110 | if (message.toLowerCase().includes("cancel")) { |
| 117 | 111 | return new ConnectionBlockedError({ |
| 118 | 112 | reason: "authentication", |
| 119 | message, | |
| 113 | detail: message, | |
| 120 | 114 | }); |
| 121 | 115 | } |
| 122 | 116 | return new ConnectionTransientError({ |
| 123 | 117 | reason: "remote-unavailable", |
| 124 | message: `Could not prepare the SSH environment: ${message}`, | |
| 118 | detail: `Could not prepare the SSH environment: ${message}`, | |
| 125 | 119 | }); |
| 126 | 120 | } |
| 127 | 121 | |
| ... | ... | @@ -139,7 +133,7 @@ export const provisionDesktopSshEnvironment = Effect.fn( |
| 139 | 133 | if (pairingToken === null) { |
| 140 | 134 | return yield* new ConnectionBlockedError({ |
| 141 | 135 | reason: "authentication", |
| 142 | message: "The SSH environment did not issue a pairing credential.", | |
| 136 | detail: "The SSH environment did not issue a pairing credential.", | |
| 143 | 137 | }); |
| 144 | 138 | } |
| 145 | 139 | const descriptor = yield* Effect.tryPromise({ |
| ... | ... | @@ -170,7 +164,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 170 | 164 | if (session === null) { |
| 171 | 165 | return yield* new ConnectionBlockedError({ |
| 172 | 166 | reason: "authentication", |
| 173 | message: "Sign in to T3 Cloud to connect this environment.", | |
| 167 | detail: "Sign in to T3 Cloud to connect this environment.", | |
| 174 | 168 | }); |
| 175 | 169 | } |
| 176 | 170 | const token = yield* session.readClerkToken().pipe( |
| ... | ... | @@ -178,14 +172,14 @@ const capabilitiesLayer = Layer.effectContext( |
| 178 | 172 | (error) => |
| 179 | 173 | new ConnectionTransientError({ |
| 180 | 174 | reason: "network", |
| 181 | message: error.message, | |
| 175 | detail: error.message, | |
| 182 | 176 | }), |
| 183 | 177 | ), |
| 184 | 178 | ); |
| 185 | 179 | if (token === null) { |
| 186 | 180 | return yield* new ConnectionBlockedError({ |
| 187 | 181 | reason: "authentication", |
| 188 | message: "The T3 Cloud session is unavailable.", | |
| 182 | detail: "The T3 Cloud session is unavailable.", | |
| 189 | 183 | }); |
| 190 | 184 | } |
| 191 | 185 | return token; |
| ... | ... | @@ -200,7 +194,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 200 | 194 | catch: (cause) => |
| 201 | 195 | new ConnectionTransientError({ |
| 202 | 196 | reason: "remote-unavailable", |
| 203 | message: `Could not load the desktop primary credential: ${String(cause)}`, | |
| 197 | detail: `Could not load the desktop primary credential: ${String(cause)}`, | |
| 204 | 198 | }), |
| 205 | 199 | }).pipe(Effect.map(Option.fromNullishOr)), |
| 206 | 200 | }); |
| ... | ... | @@ -210,7 +204,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 210 | 204 | if (bridge === undefined) { |
| 211 | 205 | return yield* new ConnectionBlockedError({ |
| 212 | 206 | reason: "unsupported", |
| 213 | message: "SSH environments are only available in the desktop app.", | |
| 207 | detail: "SSH environments are only available in the desktop app.", | |
| 214 | 208 | }); |
| 215 | 209 | } |
| 216 | 210 | return yield* provisionDesktopSshEnvironment(bridge, target); |
| ... | ... | @@ -220,7 +214,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 220 | 214 | if (bridge === undefined) { |
| 221 | 215 | return yield* new ConnectionBlockedError({ |
| 222 | 216 | reason: "unsupported", |
| 223 | message: "SSH environments are only available in the desktop app.", | |
| 217 | detail: "SSH environments are only available in the desktop app.", | |
| 224 | 218 | }); |
| 225 | 219 | } |
| 226 | 220 | const bootstrap = yield* Effect.tryPromise({ |
| ... | ... | @@ -233,7 +227,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 233 | 227 | if (bootstrap.pairingToken === null) { |
| 234 | 228 | return yield* new ConnectionBlockedError({ |
| 235 | 229 | reason: "authentication", |
| 236 | message: "The SSH environment did not issue a pairing credential.", | |
| 230 | detail: "The SSH environment did not issue a pairing credential.", | |
| 237 | 231 | }); |
| 238 | 232 | } |
| 239 | 233 | const access = yield* Effect.tryPromise({ |
| ... | ... | @@ -256,7 +250,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 256 | 250 | catch: (cause) => |
| 257 | 251 | new ConnectionTransientError({ |
| 258 | 252 | reason: "remote-unavailable", |
| 259 | message: `Could not disconnect the SSH environment: ${String(cause)}`, | |
| 253 | detail: `Could not disconnect the SSH environment: ${String(cause)}`, | |
| 260 | 254 | }), |
| 261 | 255 | }); |
| 262 | 256 | }), |
| ... | ... | @@ -278,7 +272,7 @@ const loadPrimaryConnectionRegistration = Effect.fn( |
| 278 | 272 | if (resolved === null) { |
| 279 | 273 | return yield* new ConnectionBlockedError({ |
| 280 | 274 | reason: "configuration", |
| 281 | message: "Unable to resolve the primary environment endpoint.", | |
| 275 | detail: "Unable to resolve the primary environment endpoint.", | |
| 282 | 276 | }); |
| 283 | 277 | } |
| 284 | 278 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ |
| ... | ... | @@ -342,7 +336,20 @@ const rpcRequestObserverLayer = Layer.succeed( |
| 342 | 336 | }), |
| 343 | 337 | ); |
| 344 | 338 | |
| 345 | export const connectionPlatformLayer = Layer.mergeAll( | |
| 339 | type ConnectionPlatformLayerSource = | |
| 340 | | typeof connectionStorageLayer | |
| 341 | | typeof connectivityLayer | |
| 342 | | typeof wakeupsLayer | |
| 343 | | typeof capabilitiesLayer | |
| 344 | | typeof platformConnectionSourceLayer | |
| 345 | | typeof environmentOwnedDataCleanupLayer | |
| 346 | | typeof rpcRequestObserverLayer; | |
| 347 | ||
| 348 | export const connectionPlatformLayer: Layer.Layer< | |
| 349 | Layer.Success<ConnectionPlatformLayerSource>, | |
| 350 | Layer.Error<ConnectionPlatformLayerSource>, | |
| 351 | Layer.Services<ConnectionPlatformLayerSource> | |
| 352 | > = Layer.mergeAll( | |
| 346 | 353 | connectionStorageLayer, |
| 347 | 354 | connectivityLayer, |
| 348 | 355 | wakeupsLayer, |
apps/web/src/connection/runtime.ts+18-6| ... | ... | @@ -1,16 +1,28 @@ |
| 1 | import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; | |
| 1 | import { Connection } from "@t3tools/client-runtime/connection"; | |
| 2 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| 5 | 5 | import { runtimeContextLayer } from "../lib/runtime"; |
| 6 | 6 | import { connectionPlatformLayer } from "./platform"; |
| 7 | 7 | |
| 8 | const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( | |
| 9 | Layer.provide(runtimeContextLayer), | |
| 10 | ); | |
| 8 | const providedConnectionPlatformLayer: Layer.Layer< | |
| 9 | Layer.Success<typeof connectionPlatformLayer>, | |
| 10 | Layer.Error<typeof connectionPlatformLayer> | |
| 11 | > = connectionPlatformLayer.pipe(Layer.provide(runtimeContextLayer)); | |
| 12 | ||
| 13 | type ConnectionLayerSource = | |
| 14 | | typeof Connection.layer | |
| 15 | | typeof runtimeContextLayer | |
| 16 | | typeof providedConnectionPlatformLayer; | |
| 11 | 17 | |
| 12 | export const connectionLayer = clientConnectionLayer.pipe( | |
| 18 | export const connectionLayer: Layer.Layer< | |
| 19 | Layer.Success<ConnectionLayerSource>, | |
| 20 | Layer.Error<ConnectionLayerSource> | |
| 21 | > = Connection.layer.pipe( | |
| 13 | 22 | Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), |
| 14 | 23 | ); |
| 15 | 24 | |
| 16 | export const connectionAtomRuntime = Atom.runtime(connectionLayer); | |
| 25 | export const connectionAtomRuntime: Atom.AtomRuntime< | |
| 26 | Layer.Success<typeof connectionLayer>, | |
| 27 | Layer.Error<typeof connectionLayer> | |
| 28 | > = Atom.runtime(connectionLayer); |
apps/web/src/connection/storage.test.ts+1-1| ... | ... | @@ -43,7 +43,7 @@ describe("makeCatalogStore", () => { |
| 43 | 43 | Effect.gen(function* () { |
| 44 | 44 | const failure = new ConnectionTransientError({ |
| 45 | 45 | reason: "remote-unavailable", |
| 46 | message: "permission denied", | |
| 46 | detail: "permission denied", | |
| 47 | 47 | }); |
| 48 | 48 | const store = yield* makeCatalogStore({ |
| 49 | 49 | read: Effect.fail(failure), |
apps/web/src/connection/storage.ts+10-10| ... | ... | @@ -11,11 +11,11 @@ import { |
| 11 | 11 | removeConnectionFromCatalog, |
| 12 | 12 | replaceCatalogValue, |
| 13 | 13 | } from "@t3tools/client-runtime/platform"; |
| 14 | import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; | |
| 14 | import { TokenStore } from "@t3tools/client-runtime/authorization"; | |
| 15 | 15 | import { |
| 16 | ConnectionCredentialStore, | |
| 17 | ConnectionProfileStore, | |
| 18 | 16 | ConnectionTransientError, |
| 17 | CredentialStore, | |
| 18 | ProfileStore, | |
| 19 | 19 | } from "@t3tools/client-runtime/connection"; |
| 20 | 20 | import { |
| 21 | 21 | EnvironmentId, |
| ... | ... | @@ -63,7 +63,7 @@ const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson) |
| 63 | 63 | function catalogError(operation: string, cause: unknown) { |
| 64 | 64 | return new ConnectionTransientError({ |
| 65 | 65 | reason: "remote-unavailable", |
| 66 | message: `Could not ${operation} the local connection catalog: ${String(cause)}`, | |
| 66 | detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, | |
| 67 | 67 | }); |
| 68 | 68 | } |
| 69 | 69 | |
| ... | ... | @@ -343,7 +343,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 343 | 343 | .update((document) => removeConnectionFromCatalog(document, target)) |
| 344 | 344 | .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), |
| 345 | 345 | }); |
| 346 | const profileStore = ConnectionProfileStore.of({ | |
| 346 | const profileStore = ProfileStore.make({ | |
| 347 | 347 | get: (connectionId) => |
| 348 | 348 | catalog.read.pipe( |
| 349 | 349 | Effect.map((document) => |
| ... | ... | @@ -367,7 +367,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 367 | 367 | ), |
| 368 | 368 | })), |
| 369 | 369 | }); |
| 370 | const credentialStore = ConnectionCredentialStore.of({ | |
| 370 | const credentialStore = CredentialStore.make({ | |
| 371 | 371 | get: (connectionId) => |
| 372 | 372 | catalog.read.pipe( |
| 373 | 373 | Effect.map((document) => |
| ... | ... | @@ -394,7 +394,7 @@ export const connectionStorageLayer = Layer.effectContext( |
| 394 | 394 | ), |
| 395 | 395 | })), |
| 396 | 396 | }); |
| 397 | const remoteTokenStore = RemoteDpopAccessTokenStore.of({ | |
| 397 | const remoteTokenStore = TokenStore.make({ | |
| 398 | 398 | get: (environmentId) => |
| 399 | 399 | catalog.read.pipe( |
| 400 | 400 | Effect.map((document) => |
| ... | ... | @@ -527,9 +527,9 @@ export const connectionStorageLayer = Layer.effectContext( |
| 527 | 527 | |
| 528 | 528 | return Context.make(ConnectionTargetStore, targetStore).pipe( |
| 529 | 529 | Context.add(ConnectionRegistrationStore, registrationStore), |
| 530 | Context.add(ConnectionProfileStore, profileStore), | |
| 531 | Context.add(ConnectionCredentialStore, credentialStore), | |
| 532 | Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), | |
| 530 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), | |
| 531 | Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), | |
| 532 | Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), | |
| 533 | 533 | Context.add(EnvironmentCacheStore, cacheStore), |
| 534 | 534 | ); |
| 535 | 535 | }), |
packages/client-runtime/src/authorization/index.ts+6-3| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | export * from "./layer.ts"; | |
| 2 | 1 | export * from "./remote.ts"; |
| 3 | export * from "./service.ts"; | |
| 4 | export * from "./tokenStore.ts"; | |
| 2 | export { | |
| 3 | type AuthorizedRemoteEnvironment, | |
| 4 | type RelayEnvironmentAuthorization, | |
| 5 | RemoteEnvironmentAuthorization, | |
| 6 | } from "./service.ts"; | |
| 7 | export * as TokenStore from "./tokenStore.ts"; |
packages/client-runtime/src/authorization/layer.test.ts+23-24| ... | ... | @@ -5,12 +5,11 @@ import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | import * as Ref from "effect/Ref"; |
| 7 | 7 | |
| 8 | import { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts"; | |
| 8 | import * as ManagedRelay from "../relay/managedRelay.ts"; | |
| 9 | 9 | import { remoteHttpClientLayer } from "../rpc/http.ts"; |
| 10 | import { ClientPresentation } from "../platform/capabilities.ts"; | |
| 11 | import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts"; | |
| 12 | import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; | |
| 13 | import { remoteEnvironmentAuthorizationLayer } from "./layer.ts"; | |
| 10 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 11 | import * as RemoteEnvironmentAuthorization from "./service.ts"; | |
| 12 | import * as TokenStore from "./tokenStore.ts"; | |
| 14 | 13 | |
| 15 | 14 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 16 | 15 | const ENDPOINT = { |
| ... | ... | @@ -30,7 +29,7 @@ const DESCRIPTOR = { |
| 30 | 29 | repositoryIdentity: true, |
| 31 | 30 | }, |
| 32 | 31 | }; |
| 33 | const BOOTSTRAP: RelayEnvironmentAuthorization = { | |
| 32 | const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = { | |
| 34 | 33 | environmentId: ENVIRONMENT_ID, |
| 35 | 34 | endpoint: ENDPOINT, |
| 36 | 35 | credential: "relay-bootstrap", |
| ... | ... | @@ -76,7 +75,7 @@ const authInvalid = () => |
| 76 | 75 | ); |
| 77 | 76 | |
| 78 | 77 | const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { |
| 79 | readonly initialToken?: RemoteDpopAccessToken; | |
| 78 | readonly initialToken?: TokenStore.RemoteDpopAccessToken; | |
| 80 | 79 | readonly responses: ReadonlyArray<Response>; |
| 81 | 80 | }) { |
| 82 | 81 | const tokens = yield* Ref.make( |
| ... | ... | @@ -96,7 +95,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 96 | 95 | >([]); |
| 97 | 96 | const fetch = recordedFetch(input.responses); |
| 98 | 97 | |
| 99 | const tokenStore = RemoteDpopAccessTokenStore.of({ | |
| 98 | const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ | |
| 100 | 99 | get: (environmentId) => |
| 101 | 100 | Ref.get(tokens).pipe( |
| 102 | 101 | Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), |
| ... | ... | @@ -114,23 +113,23 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 114 | 113 | return next; |
| 115 | 114 | }), |
| 116 | 115 | }); |
| 117 | const signer = ManagedRelayDpopSigner.of({ | |
| 116 | const signer = ManagedRelay.ManagedRelayDpopSigner.of({ | |
| 118 | 117 | thumbprint: Effect.succeed("thumbprint-1"), |
| 119 | 118 | createProof: (proofInput) => |
| 120 | 119 | Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( |
| 121 | 120 | Effect.as(`proof:${proofInput.url}`), |
| 122 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), | |
| 121 | Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })), | |
| 123 | 122 | ), |
| 124 | 123 | }); |
| 125 | const layer = remoteEnvironmentAuthorizationLayer.pipe( | |
| 124 | const layer = RemoteEnvironmentAuthorization.layer.pipe( | |
| 126 | 125 | Layer.provide( |
| 127 | 126 | Layer.mergeAll( |
| 128 | 127 | remoteHttpClientLayer(fetch.fetchFn), |
| 129 | Layer.succeed(ManagedRelayDpopSigner, signer), | |
| 130 | Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), | |
| 128 | Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, signer), | |
| 129 | Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), | |
| 131 | 130 | Layer.succeed( |
| 132 | ClientPresentation, | |
| 133 | ClientPresentation.of({ | |
| 131 | ClientCapabilities.ClientPresentation, | |
| 132 | ClientCapabilities.ClientPresentation.of({ | |
| 134 | 133 | metadata: { |
| 135 | 134 | label: "T3 Code Test", |
| 136 | 135 | deviceType: "mobile", |
| ... | ... | @@ -159,7 +158,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 159 | 158 | describe("RemoteEnvironmentAuthorization", () => { |
| 160 | 159 | it.effect("reuses a valid persisted environment token without contacting the relay", () => |
| 161 | 160 | Effect.gen(function* () { |
| 162 | const cached = new RemoteDpopAccessToken({ | |
| 161 | const cached = new TokenStore.RemoteDpopAccessToken({ | |
| 163 | 162 | environmentId: ENVIRONMENT_ID, |
| 164 | 163 | label: DESCRIPTOR.label, |
| 165 | 164 | endpoint: ENDPOINT, |
| ... | ... | @@ -173,7 +172,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 173 | 172 | }); |
| 174 | 173 | |
| 175 | 174 | const authorized = yield* Effect.gen(function* () { |
| 176 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 175 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 177 | 176 | return yield* remote.authorizeDpop({ |
| 178 | 177 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 179 | 178 | obtainBootstrap: harness.obtainBootstrap, |
| ... | ... | @@ -191,7 +190,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 191 | 190 | |
| 192 | 191 | it.effect("refreshes and persists an expired environment token", () => |
| 193 | 192 | Effect.gen(function* () { |
| 194 | const expired = new RemoteDpopAccessToken({ | |
| 193 | const expired = new TokenStore.RemoteDpopAccessToken({ | |
| 195 | 194 | environmentId: ENVIRONMENT_ID, |
| 196 | 195 | label: DESCRIPTOR.label, |
| 197 | 196 | endpoint: ENDPOINT, |
| ... | ... | @@ -209,7 +208,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 209 | 208 | }); |
| 210 | 209 | |
| 211 | 210 | const authorized = yield* Effect.gen(function* () { |
| 212 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 211 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 213 | 212 | return yield* remote.authorizeDpop({ |
| 214 | 213 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 215 | 214 | obtainBootstrap: harness.obtainBootstrap, |
| ... | ... | @@ -230,7 +229,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 230 | 229 | |
| 231 | 230 | it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => |
| 232 | 231 | Effect.gen(function* () { |
| 233 | const cached = new RemoteDpopAccessToken({ | |
| 232 | const cached = new TokenStore.RemoteDpopAccessToken({ | |
| 234 | 233 | environmentId: ENVIRONMENT_ID, |
| 235 | 234 | label: DESCRIPTOR.label, |
| 236 | 235 | endpoint: ENDPOINT, |
| ... | ... | @@ -249,7 +248,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 249 | 248 | }); |
| 250 | 249 | |
| 251 | 250 | const authorized = yield* Effect.gen(function* () { |
| 252 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 251 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 253 | 252 | return yield* remote.authorizeDpop({ |
| 254 | 253 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 255 | 254 | obtainBootstrap: harness.obtainBootstrap, |
| ... | ... | @@ -269,7 +268,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 269 | 268 | |
| 270 | 269 | it.effect("refreshes a cached endpoint after consecutive transient failures", () => |
| 271 | 270 | Effect.gen(function* () { |
| 272 | const cached = new RemoteDpopAccessToken({ | |
| 271 | const cached = new TokenStore.RemoteDpopAccessToken({ | |
| 273 | 272 | environmentId: ENVIRONMENT_ID, |
| 274 | 273 | label: DESCRIPTOR.label, |
| 275 | 274 | endpoint: ENDPOINT, |
| ... | ... | @@ -289,7 +288,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 289 | 288 | }); |
| 290 | 289 | |
| 291 | 290 | const authorized = yield* Effect.gen(function* () { |
| 292 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 291 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 293 | 292 | const firstFailure = yield* remote |
| 294 | 293 | .authorizeDpop({ |
| 295 | 294 | expectedEnvironmentId: ENVIRONMENT_ID, |
| ... | ... | @@ -329,7 +328,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 329 | 328 | }); |
| 330 | 329 | |
| 331 | 330 | yield* Effect.gen(function* () { |
| 332 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 331 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 333 | 332 | return yield* remote.authorizeDpop({ |
| 334 | 333 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 335 | 334 | obtainBootstrap: harness.obtainBootstrap, |
packages/client-runtime/src/authorization/layer.ts deleted-268| ... | ... | @@ -1,268 +0,0 @@ |
| 1 | import { | |
| 2 | exchangeRemoteDpopAccessToken, | |
| 3 | type RemoteEnvironmentAuthError, | |
| 4 | resolveRemoteDpopWebSocketConnectionUrl, | |
| 5 | resolveRemoteWebSocketConnectionUrl, | |
| 6 | } from "./remote.ts"; | |
| 7 | import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; | |
| 8 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; | |
| 9 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | |
| 10 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | |
| 11 | import { ClientPresentation } from "../platform/capabilities.ts"; | |
| 12 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; | |
| 13 | import { RemoteEnvironmentAuthorization } from "./service.ts"; | |
| 14 | import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; | |
| 15 | import * as Clock from "effect/Clock"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Ref from "effect/Ref"; | |
| 20 | import * as Result from "effect/Result"; | |
| 21 | import { HttpClient } from "effect/unstable/http"; | |
| 22 | ||
| 23 | const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; | |
| 24 | const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; | |
| 25 | ||
| 26 | function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { | |
| 27 | return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" | |
| 28 | ? error | |
| 29 | : mapRemoteEnvironmentError(error); | |
| 30 | } | |
| 31 | ||
| 32 | const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( | |
| 33 | httpBaseUrl: string, | |
| 34 | ) { | |
| 35 | return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( | |
| 36 | Effect.mapError(mapRemoteEnvironmentError), | |
| 37 | ); | |
| 38 | }); | |
| 39 | ||
| 40 | export const remoteEnvironmentAuthorizationLayer = Layer.effect( | |
| 41 | RemoteEnvironmentAuthorization, | |
| 42 | Effect.gen(function* () { | |
| 43 | const signer = yield* ManagedRelayDpopSigner; | |
| 44 | const presentation = yield* ClientPresentation; | |
| 45 | const tokenStore = yield* RemoteDpopAccessTokenStore; | |
| 46 | const httpClient = yield* HttpClient.HttpClient; | |
| 47 | const cachedEndpointFailures = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | |
| 48 | ||
| 49 | const resetCachedEndpointFailures = (environmentId: string) => | |
| 50 | Ref.update(cachedEndpointFailures, (current) => { | |
| 51 | if (!current.has(environmentId)) { | |
| 52 | return current; | |
| 53 | } | |
| 54 | const next = new Map(current); | |
| 55 | next.delete(environmentId); | |
| 56 | return next; | |
| 57 | }); | |
| 58 | ||
| 59 | const recordCachedEndpointFailure = (environmentId: string) => | |
| 60 | Ref.modify(cachedEndpointFailures, (current) => { | |
| 61 | const failureCount = (current.get(environmentId) ?? 0) + 1; | |
| 62 | const next = new Map(current); | |
| 63 | next.set(environmentId, failureCount); | |
| 64 | return [failureCount, next] as const; | |
| 65 | }); | |
| 66 | ||
| 67 | const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( | |
| 68 | function* (input: { | |
| 69 | readonly expectedEnvironmentId: Parameters< | |
| 70 | RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] | |
| 71 | >[0]["expectedEnvironmentId"]; | |
| 72 | readonly httpBaseUrl: string; | |
| 73 | readonly wsBaseUrl: string; | |
| 74 | readonly bearerToken: string; | |
| 75 | }) { | |
| 76 | const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( | |
| 77 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 78 | ); | |
| 79 | if (descriptor.environmentId !== input.expectedEnvironmentId) { | |
| 80 | return yield* environmentMismatchError({ | |
| 81 | expected: input.expectedEnvironmentId, | |
| 82 | actual: descriptor.environmentId, | |
| 83 | }); | |
| 84 | } | |
| 85 | const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ | |
| 86 | wsBaseUrl: input.wsBaseUrl, | |
| 87 | httpBaseUrl: input.httpBaseUrl, | |
| 88 | bearerToken: input.bearerToken, | |
| 89 | }).pipe( | |
| 90 | Effect.mapError(mapRemoteEnvironmentError), | |
| 91 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 92 | ); | |
| 93 | return { | |
| 94 | environmentId: descriptor.environmentId, | |
| 95 | label: descriptor.label, | |
| 96 | httpBaseUrl: input.httpBaseUrl, | |
| 97 | socketUrl, | |
| 98 | httpAuthorization: { | |
| 99 | _tag: "Bearer" as const, | |
| 100 | token: input.bearerToken, | |
| 101 | }, | |
| 102 | }; | |
| 103 | }, | |
| 104 | ); | |
| 105 | ||
| 106 | const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( | |
| 107 | function* (token: RemoteDpopAccessToken) { | |
| 108 | const ticketProof = yield* signer | |
| 109 | .createProof({ | |
| 110 | method: "POST", | |
| 111 | url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), | |
| 112 | accessToken: token.accessToken, | |
| 113 | }) | |
| 114 | .pipe( | |
| 115 | Effect.mapError( | |
| 116 | () => | |
| 117 | new ConnectionBlockedError({ | |
| 118 | reason: "configuration", | |
| 119 | message: "Could not create the websocket authorization proof.", | |
| 120 | }), | |
| 121 | ), | |
| 122 | ); | |
| 123 | return yield* resolveRemoteDpopWebSocketConnectionUrl({ | |
| 124 | wsBaseUrl: token.endpoint.wsBaseUrl, | |
| 125 | httpBaseUrl: token.endpoint.httpBaseUrl, | |
| 126 | accessToken: token.accessToken, | |
| 127 | dpopProof: ticketProof, | |
| 128 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); | |
| 129 | }, | |
| 130 | ); | |
| 131 | ||
| 132 | const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( | |
| 133 | function* (input: { | |
| 134 | readonly expectedEnvironmentId: Parameters< | |
| 135 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | |
| 136 | >[0]["expectedEnvironmentId"]; | |
| 137 | readonly obtainBootstrap: Parameters< | |
| 138 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | |
| 139 | >[0]["obtainBootstrap"]; | |
| 140 | }) { | |
| 141 | const thumbprint = yield* signer.thumbprint.pipe( | |
| 142 | Effect.mapError( | |
| 143 | () => | |
| 144 | new ConnectionBlockedError({ | |
| 145 | reason: "configuration", | |
| 146 | message: "Could not load the environment authorization key.", | |
| 147 | }), | |
| 148 | ), | |
| 149 | Effect.withSpan("environment.authorization.dpopKey.resolve"), | |
| 150 | ); | |
| 151 | const now = yield* Clock.currentTimeMillis; | |
| 152 | const cached = yield* tokenStore | |
| 153 | .get(input.expectedEnvironmentId) | |
| 154 | .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); | |
| 155 | if ( | |
| 156 | Option.isSome(cached) && | |
| 157 | cached.value.environmentId === input.expectedEnvironmentId && | |
| 158 | cached.value.dpopThumbprint === thumbprint && | |
| 159 | cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS | |
| 160 | ) { | |
| 161 | yield* Effect.annotateCurrentSpan({ | |
| 162 | "connection.remote_token_cache": "hit", | |
| 163 | }); | |
| 164 | const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); | |
| 165 | if (Result.isSuccess(cachedSocket)) { | |
| 166 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 167 | return { | |
| 168 | environmentId: cached.value.environmentId, | |
| 169 | label: cached.value.label, | |
| 170 | httpBaseUrl: cached.value.endpoint.httpBaseUrl, | |
| 171 | socketUrl: cachedSocket.success, | |
| 172 | httpAuthorization: { | |
| 173 | _tag: "Dpop" as const, | |
| 174 | accessToken: cached.value.accessToken, | |
| 175 | }, | |
| 176 | }; | |
| 177 | } | |
| 178 | if (cachedSocket.failure._tag === "ConnectionBlockedError") { | |
| 179 | return yield* mapDpopSocketError(cachedSocket.failure); | |
| 180 | } | |
| 181 | const mappedFailure = mapDpopSocketError(cachedSocket.failure); | |
| 182 | if (mappedFailure._tag === "ConnectionTransientError") { | |
| 183 | const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); | |
| 184 | if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { | |
| 185 | return yield* mappedFailure; | |
| 186 | } | |
| 187 | } | |
| 188 | yield* tokenStore | |
| 189 | .remove(input.expectedEnvironmentId) | |
| 190 | .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); | |
| 191 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 192 | } | |
| 193 | ||
| 194 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 195 | yield* Effect.annotateCurrentSpan({ | |
| 196 | "connection.remote_token_cache": "miss", | |
| 197 | }); | |
| 198 | const bootstrap = yield* input.obtainBootstrap; | |
| 199 | const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( | |
| 200 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 201 | Effect.withSpan("environment.authorization.descriptor"), | |
| 202 | ); | |
| 203 | if (descriptor.environmentId !== input.expectedEnvironmentId) { | |
| 204 | return yield* environmentMismatchError({ | |
| 205 | expected: input.expectedEnvironmentId, | |
| 206 | actual: descriptor.environmentId, | |
| 207 | }); | |
| 208 | } | |
| 209 | const bootstrapProof = yield* signer | |
| 210 | .createProof({ | |
| 211 | method: "POST", | |
| 212 | url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), | |
| 213 | }) | |
| 214 | .pipe( | |
| 215 | Effect.mapError( | |
| 216 | () => | |
| 217 | new ConnectionBlockedError({ | |
| 218 | reason: "configuration", | |
| 219 | message: "Could not create the environment authorization proof.", | |
| 220 | }), | |
| 221 | ), | |
| 222 | ); | |
| 223 | const access = yield* exchangeRemoteDpopAccessToken({ | |
| 224 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, | |
| 225 | credential: bootstrap.credential, | |
| 226 | dpopProof: bootstrapProof, | |
| 227 | scopes: presentation.scopes, | |
| 228 | clientMetadata: presentation.metadata, | |
| 229 | }).pipe( | |
| 230 | Effect.mapError(mapRemoteEnvironmentError), | |
| 231 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 232 | Effect.withSpan("environment.authorization.accessToken.exchange"), | |
| 233 | ); | |
| 234 | const issuedAt = yield* Clock.currentTimeMillis; | |
| 235 | const token = new RemoteDpopAccessToken({ | |
| 236 | environmentId: descriptor.environmentId, | |
| 237 | label: descriptor.label, | |
| 238 | endpoint: bootstrap.endpoint, | |
| 239 | accessToken: access.access_token, | |
| 240 | expiresAtEpochMs: issuedAt + access.expires_in * 1_000, | |
| 241 | dpopThumbprint: thumbprint, | |
| 242 | }); | |
| 243 | const socketUrl = yield* createDpopSocketUrl(token).pipe( | |
| 244 | Effect.mapError(mapDpopSocketError), | |
| 245 | ); | |
| 246 | yield* tokenStore | |
| 247 | .put(token) | |
| 248 | .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); | |
| 249 | return { | |
| 250 | environmentId: descriptor.environmentId, | |
| 251 | label: descriptor.label, | |
| 252 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, | |
| 253 | socketUrl, | |
| 254 | httpAuthorization: { | |
| 255 | _tag: "Dpop" as const, | |
| 256 | accessToken: token.accessToken, | |
| 257 | }, | |
| 258 | }; | |
| 259 | }, | |
| 260 | ); | |
| 261 | ||
| 262 | return RemoteEnvironmentAuthorization.of({ | |
| 263 | authorizeBearer, | |
| 264 | authorizeDpop: (input) => | |
| 265 | authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), | |
| 266 | }); | |
| 267 | }), | |
| 268 | ); |
packages/client-runtime/src/authorization/service.ts+265-2| ... | ... | @@ -1,9 +1,28 @@ |
| 1 | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; |
| 3 | import { | |
| 4 | exchangeRemoteDpopAccessToken, | |
| 5 | type RemoteEnvironmentAuthError, | |
| 6 | resolveRemoteDpopWebSocketConnectionUrl, | |
| 7 | resolveRemoteWebSocketConnectionUrl, | |
| 8 | } from "./remote.ts"; | |
| 9 | import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; | |
| 10 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; | |
| 11 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; | |
| 12 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | |
| 13 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 14 | import * as ManagedRelay from "../relay/managedRelay.ts"; | |
| 15 | import * as TokenStore from "./tokenStore.ts"; | |
| 16 | import * as Clock from "effect/Clock"; | |
| 3 | 17 | import * as Context from "effect/Context"; |
| 4 | import type * as Effect from "effect/Effect"; | |
| 18 | import * as Effect from "effect/Effect"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as Option from "effect/Option"; | |
| 21 | import * as Ref from "effect/Ref"; | |
| 22 | import * as Result from "effect/Result"; | |
| 23 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 5 | 24 | |
| 6 | import type { ConnectionAttemptError, PreparedHttpAuthorization } from "../connection/model.ts"; | |
| 25 | import type { PreparedHttpAuthorization } from "../connection/model.ts"; | |
| 7 | 26 | |
| 8 | 27 | export interface RelayEnvironmentAuthorization { |
| 9 | 28 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -37,3 +56,247 @@ export class RemoteEnvironmentAuthorization extends Context.Service< |
| 37 | 56 | }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>; |
| 38 | 57 | } |
| 39 | 58 | >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} |
| 59 | ||
| 60 | const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; | |
| 61 | const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; | |
| 62 | ||
| 63 | function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { | |
| 64 | return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" | |
| 65 | ? error | |
| 66 | : mapRemoteEnvironmentError(error); | |
| 67 | } | |
| 68 | ||
| 69 | const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( | |
| 70 | httpBaseUrl: string, | |
| 71 | ) { | |
| 72 | return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( | |
| 73 | Effect.mapError(mapRemoteEnvironmentError), | |
| 74 | ); | |
| 75 | }); | |
| 76 | ||
| 77 | export const make = Effect.gen(function* () { | |
| 78 | const signer = yield* ManagedRelay.ManagedRelayDpopSigner; | |
| 79 | const presentation = yield* ClientCapabilities.ClientPresentation; | |
| 80 | const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; | |
| 81 | const httpClient = yield* HttpClient.HttpClient; | |
| 82 | const cachedEndpointFailures = yield* Ref.make<ReadonlyMap<string, number>>(new Map()); | |
| 83 | ||
| 84 | const resetCachedEndpointFailures = (environmentId: string) => | |
| 85 | Ref.update(cachedEndpointFailures, (current) => { | |
| 86 | if (!current.has(environmentId)) { | |
| 87 | return current; | |
| 88 | } | |
| 89 | const next = new Map(current); | |
| 90 | next.delete(environmentId); | |
| 91 | return next; | |
| 92 | }); | |
| 93 | ||
| 94 | const recordCachedEndpointFailure = (environmentId: string) => | |
| 95 | Ref.modify(cachedEndpointFailures, (current) => { | |
| 96 | const failureCount = (current.get(environmentId) ?? 0) + 1; | |
| 97 | const next = new Map(current); | |
| 98 | next.set(environmentId, failureCount); | |
| 99 | return [failureCount, next] as const; | |
| 100 | }); | |
| 101 | ||
| 102 | const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( | |
| 103 | function* (input: { | |
| 104 | readonly expectedEnvironmentId: Parameters< | |
| 105 | RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] | |
| 106 | >[0]["expectedEnvironmentId"]; | |
| 107 | readonly httpBaseUrl: string; | |
| 108 | readonly wsBaseUrl: string; | |
| 109 | readonly bearerToken: string; | |
| 110 | }) { | |
| 111 | const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( | |
| 112 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 113 | ); | |
| 114 | if (descriptor.environmentId !== input.expectedEnvironmentId) { | |
| 115 | return yield* environmentMismatchError({ | |
| 116 | expected: input.expectedEnvironmentId, | |
| 117 | actual: descriptor.environmentId, | |
| 118 | }); | |
| 119 | } | |
| 120 | const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ | |
| 121 | wsBaseUrl: input.wsBaseUrl, | |
| 122 | httpBaseUrl: input.httpBaseUrl, | |
| 123 | bearerToken: input.bearerToken, | |
| 124 | }).pipe( | |
| 125 | Effect.mapError(mapRemoteEnvironmentError), | |
| 126 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 127 | ); | |
| 128 | return { | |
| 129 | environmentId: descriptor.environmentId, | |
| 130 | label: descriptor.label, | |
| 131 | httpBaseUrl: input.httpBaseUrl, | |
| 132 | socketUrl, | |
| 133 | httpAuthorization: { | |
| 134 | _tag: "Bearer" as const, | |
| 135 | token: input.bearerToken, | |
| 136 | }, | |
| 137 | }; | |
| 138 | }, | |
| 139 | ); | |
| 140 | ||
| 141 | const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( | |
| 142 | function* (token: TokenStore.RemoteDpopAccessToken) { | |
| 143 | const ticketProof = yield* signer | |
| 144 | .createProof({ | |
| 145 | method: "POST", | |
| 146 | url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), | |
| 147 | accessToken: token.accessToken, | |
| 148 | }) | |
| 149 | .pipe( | |
| 150 | Effect.mapError( | |
| 151 | () => | |
| 152 | new ConnectionBlockedError({ | |
| 153 | reason: "configuration", | |
| 154 | detail: "Could not create the websocket authorization proof.", | |
| 155 | }), | |
| 156 | ), | |
| 157 | ); | |
| 158 | return yield* resolveRemoteDpopWebSocketConnectionUrl({ | |
| 159 | wsBaseUrl: token.endpoint.wsBaseUrl, | |
| 160 | httpBaseUrl: token.endpoint.httpBaseUrl, | |
| 161 | accessToken: token.accessToken, | |
| 162 | dpopProof: ticketProof, | |
| 163 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); | |
| 164 | }, | |
| 165 | ); | |
| 166 | ||
| 167 | const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( | |
| 168 | function* (input: { | |
| 169 | readonly expectedEnvironmentId: Parameters< | |
| 170 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | |
| 171 | >[0]["expectedEnvironmentId"]; | |
| 172 | readonly obtainBootstrap: Parameters< | |
| 173 | RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] | |
| 174 | >[0]["obtainBootstrap"]; | |
| 175 | }) { | |
| 176 | const thumbprint = yield* signer.thumbprint.pipe( | |
| 177 | Effect.mapError( | |
| 178 | () => | |
| 179 | new ConnectionBlockedError({ | |
| 180 | reason: "configuration", | |
| 181 | detail: "Could not load the environment authorization key.", | |
| 182 | }), | |
| 183 | ), | |
| 184 | Effect.withSpan("environment.authorization.dpopKey.resolve"), | |
| 185 | ); | |
| 186 | const now = yield* Clock.currentTimeMillis; | |
| 187 | const cached = yield* tokenStore | |
| 188 | .get(input.expectedEnvironmentId) | |
| 189 | .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); | |
| 190 | if ( | |
| 191 | Option.isSome(cached) && | |
| 192 | cached.value.environmentId === input.expectedEnvironmentId && | |
| 193 | cached.value.dpopThumbprint === thumbprint && | |
| 194 | cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS | |
| 195 | ) { | |
| 196 | yield* Effect.annotateCurrentSpan({ | |
| 197 | "connection.remote_token_cache": "hit", | |
| 198 | }); | |
| 199 | const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); | |
| 200 | if (Result.isSuccess(cachedSocket)) { | |
| 201 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 202 | return { | |
| 203 | environmentId: cached.value.environmentId, | |
| 204 | label: cached.value.label, | |
| 205 | httpBaseUrl: cached.value.endpoint.httpBaseUrl, | |
| 206 | socketUrl: cachedSocket.success, | |
| 207 | httpAuthorization: { | |
| 208 | _tag: "Dpop" as const, | |
| 209 | accessToken: cached.value.accessToken, | |
| 210 | }, | |
| 211 | }; | |
| 212 | } | |
| 213 | if (cachedSocket.failure._tag === "ConnectionBlockedError") { | |
| 214 | return yield* mapDpopSocketError(cachedSocket.failure); | |
| 215 | } | |
| 216 | const mappedFailure = mapDpopSocketError(cachedSocket.failure); | |
| 217 | if (mappedFailure._tag === "ConnectionTransientError") { | |
| 218 | const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); | |
| 219 | if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { | |
| 220 | return yield* mappedFailure; | |
| 221 | } | |
| 222 | } | |
| 223 | yield* tokenStore | |
| 224 | .remove(input.expectedEnvironmentId) | |
| 225 | .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); | |
| 226 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 227 | } | |
| 228 | ||
| 229 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 230 | yield* Effect.annotateCurrentSpan({ | |
| 231 | "connection.remote_token_cache": "miss", | |
| 232 | }); | |
| 233 | const bootstrap = yield* input.obtainBootstrap; | |
| 234 | const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( | |
| 235 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 236 | Effect.withSpan("environment.authorization.descriptor"), | |
| 237 | ); | |
| 238 | if (descriptor.environmentId !== input.expectedEnvironmentId) { | |
| 239 | return yield* environmentMismatchError({ | |
| 240 | expected: input.expectedEnvironmentId, | |
| 241 | actual: descriptor.environmentId, | |
| 242 | }); | |
| 243 | } | |
| 244 | const bootstrapProof = yield* signer | |
| 245 | .createProof({ | |
| 246 | method: "POST", | |
| 247 | url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), | |
| 248 | }) | |
| 249 | .pipe( | |
| 250 | Effect.mapError( | |
| 251 | () => | |
| 252 | new ConnectionBlockedError({ | |
| 253 | reason: "configuration", | |
| 254 | detail: "Could not create the environment authorization proof.", | |
| 255 | }), | |
| 256 | ), | |
| 257 | ); | |
| 258 | const access = yield* exchangeRemoteDpopAccessToken({ | |
| 259 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, | |
| 260 | credential: bootstrap.credential, | |
| 261 | dpopProof: bootstrapProof, | |
| 262 | scopes: presentation.scopes, | |
| 263 | clientMetadata: presentation.metadata, | |
| 264 | }).pipe( | |
| 265 | Effect.mapError(mapRemoteEnvironmentError), | |
| 266 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 267 | Effect.withSpan("environment.authorization.accessToken.exchange"), | |
| 268 | ); | |
| 269 | const issuedAt = yield* Clock.currentTimeMillis; | |
| 270 | const token = new TokenStore.RemoteDpopAccessToken({ | |
| 271 | environmentId: descriptor.environmentId, | |
| 272 | label: descriptor.label, | |
| 273 | endpoint: bootstrap.endpoint, | |
| 274 | accessToken: access.access_token, | |
| 275 | expiresAtEpochMs: issuedAt + access.expires_in * 1_000, | |
| 276 | dpopThumbprint: thumbprint, | |
| 277 | }); | |
| 278 | const socketUrl = yield* createDpopSocketUrl(token).pipe(Effect.mapError(mapDpopSocketError)); | |
| 279 | yield* tokenStore | |
| 280 | .put(token) | |
| 281 | .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); | |
| 282 | return { | |
| 283 | environmentId: descriptor.environmentId, | |
| 284 | label: descriptor.label, | |
| 285 | httpBaseUrl: bootstrap.endpoint.httpBaseUrl, | |
| 286 | socketUrl, | |
| 287 | httpAuthorization: { | |
| 288 | _tag: "Dpop" as const, | |
| 289 | accessToken: token.accessToken, | |
| 290 | }, | |
| 291 | }; | |
| 292 | }, | |
| 293 | ); | |
| 294 | ||
| 295 | return RemoteEnvironmentAuthorization.of({ | |
| 296 | authorizeBearer, | |
| 297 | authorizeDpop: (input) => | |
| 298 | authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), | |
| 299 | }); | |
| 300 | }); | |
| 301 | ||
| 302 | export const layer = Layer.effect(RemoteEnvironmentAuthorization, make); |
packages/client-runtime/src/authorization/tokenStore.ts+7| ... | ... | @@ -2,6 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { RelayManagedEndpoint } from "@t3tools/contracts/relay"; |
| 3 | 3 | import * as Context from "effect/Context"; |
| 4 | 4 | import type * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | |
| 5 | 6 | import type * as Option from "effect/Option"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | |
| ... | ... | @@ -28,3 +29,9 @@ export class RemoteDpopAccessTokenStore extends Context.Service< |
| 28 | 29 | readonly remove: (environmentId: EnvironmentId) => Effect.Effect<void, ConnectionAttemptError>; |
| 29 | 30 | } |
| 30 | 31 | >()("@t3tools/client-runtime/authorization/tokenStore/RemoteDpopAccessTokenStore") {} |
| 32 | ||
| 33 | export const make = (service: RemoteDpopAccessTokenStore["Service"]) => | |
| 34 | RemoteDpopAccessTokenStore.of(service); | |
| 35 | ||
| 36 | export const layer = (service: RemoteDpopAccessTokenStore["Service"]) => | |
| 37 | Layer.succeed(RemoteDpopAccessTokenStore, make(service)); |
packages/client-runtime/src/connection/catalog.ts-28| ... | ... | @@ -1,10 +1,7 @@ |
| 1 | 1 | import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import type * as Effect from "effect/Effect"; | |
| 4 | 2 | import * as Option from "effect/Option"; |
| 5 | 3 | import * as Schema from "effect/Schema"; |
| 6 | 4 | |
| 7 | import type { ConnectionAttemptError } from "./model.ts"; | |
| 8 | 5 | import { |
| 9 | 6 | BearerConnectionTarget, |
| 10 | 7 | PrimaryConnectionTarget, |
| ... | ... | @@ -116,28 +113,3 @@ export function connectionRegistrationCatalogEntry( |
| 116 | 113 | }; |
| 117 | 114 | } |
| 118 | 115 | } |
| 119 | ||
| 120 | export class ConnectionProfileStore extends Context.Service< | |
| 121 | ConnectionProfileStore, | |
| 122 | { | |
| 123 | readonly get: ( | |
| 124 | connectionId: string, | |
| 125 | ) => Effect.Effect<Option.Option<ConnectionProfile>, ConnectionAttemptError>; | |
| 126 | readonly put: (profile: ConnectionProfile) => Effect.Effect<void, ConnectionAttemptError>; | |
| 127 | readonly remove: (connectionId: string) => Effect.Effect<void, ConnectionAttemptError>; | |
| 128 | } | |
| 129 | >()("@t3tools/client-runtime/connection/catalog/ConnectionProfileStore") {} | |
| 130 | ||
| 131 | export class ConnectionCredentialStore extends Context.Service< | |
| 132 | ConnectionCredentialStore, | |
| 133 | { | |
| 134 | readonly get: ( | |
| 135 | connectionId: string, | |
| 136 | ) => Effect.Effect<Option.Option<ConnectionCredential>, ConnectionAttemptError>; | |
| 137 | readonly put: ( | |
| 138 | connectionId: string, | |
| 139 | credential: ConnectionCredential, | |
| 140 | ) => Effect.Effect<void, ConnectionAttemptError>; | |
| 141 | readonly remove: (connectionId: string) => Effect.Effect<void, ConnectionAttemptError>; | |
| 142 | } | |
| 143 | >()("@t3tools/client-runtime/connection/catalog/ConnectionCredentialStore") {} |
packages/client-runtime/src/connection/connectivity.ts+6| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import type * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | |
| 3 | 4 | import type * as Stream from "effect/Stream"; |
| 4 | 5 | |
| 5 | 6 | import type { NetworkStatus } from "./model.ts"; |
| ... | ... | @@ -11,3 +12,8 @@ export class Connectivity extends Context.Service< |
| 11 | 12 | readonly changes: Stream.Stream<NetworkStatus>; |
| 12 | 13 | } |
| 13 | 14 | >()("@t3tools/client-runtime/connection/connectivity") {} |
| 15 | ||
| 16 | export const make = (service: Connectivity["Service"]) => Connectivity.of(service); | |
| 17 | ||
| 18 | export const layer = (service: Connectivity["Service"]) => | |
| 19 | Layer.succeed(Connectivity, make(service)); |
packages/client-runtime/src/connection/credentialStore.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import type * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import type * as Option from "effect/Option"; | |
| 5 | ||
| 6 | import type { ConnectionCredential } from "./catalog.ts"; | |
| 7 | import type { ConnectionAttemptError } from "./model.ts"; | |
| 8 | ||
| 9 | export class ConnectionCredentialStore extends Context.Service< | |
| 10 | ConnectionCredentialStore, | |
| 11 | { | |
| 12 | readonly get: ( | |
| 13 | connectionId: string, | |
| 14 | ) => Effect.Effect<Option.Option<ConnectionCredential>, ConnectionAttemptError>; | |
| 15 | readonly put: ( | |
| 16 | connectionId: string, | |
| 17 | credential: ConnectionCredential, | |
| 18 | ) => Effect.Effect<void, ConnectionAttemptError>; | |
| 19 | readonly remove: (connectionId: string) => Effect.Effect<void, ConnectionAttemptError>; | |
| 20 | } | |
| 21 | >()("@t3tools/client-runtime/connection/credentialStore/ConnectionCredentialStore") {} | |
| 22 | ||
| 23 | export const make = (service: ConnectionCredentialStore["Service"]) => | |
| 24 | ConnectionCredentialStore.of(service); | |
| 25 | ||
| 26 | export const layer = (service: ConnectionCredentialStore["Service"]) => | |
| 27 | Layer.succeed(ConnectionCredentialStore, make(service)); |
packages/client-runtime/src/connection/driver.ts+34-36| ... | ... | @@ -9,8 +9,8 @@ import type { |
| 9 | 9 | ConnectionAttemptStage, |
| 10 | 10 | PreparedConnection, |
| 11 | 11 | } from "./model.ts"; |
| 12 | import { ConnectionResolver } from "./resolver.ts"; | |
| 13 | import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts"; | |
| 12 | import * as ConnectionResolver from "./resolver.ts"; | |
| 13 | import * as RpcSession from "../rpc/session.ts"; | |
| 14 | 14 | |
| 15 | 15 | export type ConnectionDriverProgress = |
| 16 | 16 | | { |
| ... | ... | @@ -23,44 +23,42 @@ export type ConnectionDriverProgress = |
| 23 | 23 | |
| 24 | 24 | export interface EnvironmentConnectionLease { |
| 25 | 25 | readonly prepared: PreparedConnection; |
| 26 | readonly session: RpcSession; | |
| 26 | readonly session: RpcSession.RpcSession; | |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | export interface ConnectionDriverService { | |
| 30 | readonly connect: ( | |
| 31 | entry: ConnectionCatalogEntry, | |
| 32 | reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>, | |
| 33 | ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>; | |
| 34 | } | |
| 35 | ||
| 36 | export class ConnectionDriver extends Context.Service<ConnectionDriver, ConnectionDriverService>()( | |
| 37 | "@t3tools/client-runtime/connection/driver/ConnectionDriver", | |
| 38 | ) {} | |
| 39 | ||
| 40 | export const connectionDriverLayer = Layer.effect( | |
| 29 | export class ConnectionDriver extends Context.Service< | |
| 41 | 30 | ConnectionDriver, |
| 42 | Effect.gen(function* () { | |
| 43 | const resolver = yield* ConnectionResolver; | |
| 44 | const sessions = yield* RpcSessionFactory; | |
| 45 | ||
| 46 | const connect = Effect.fn("ConnectionDriver.connect")(function* ( | |
| 31 | { | |
| 32 | readonly connect: ( | |
| 47 | 33 | entry: ConnectionCatalogEntry, |
| 48 | 34 | reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>, |
| 49 | ) { | |
| 50 | const target = entry.target; | |
| 51 | yield* Effect.annotateCurrentSpan({ | |
| 52 | "connection.environment.id": target.environmentId, | |
| 53 | "connection.target.kind": target._tag, | |
| 54 | }); | |
| 55 | yield* reportProgress({ stage: "preparing" }); | |
| 56 | const prepared = yield* resolver.prepare(entry); | |
| 57 | yield* reportProgress({ stage: "opening", prepared }); | |
| 58 | const session = yield* sessions.connect(prepared); | |
| 59 | yield* reportProgress({ stage: "synchronizing", prepared }); | |
| 60 | yield* session.ready; | |
| 61 | return { prepared, session } satisfies EnvironmentConnectionLease; | |
| 35 | ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>; | |
| 36 | } | |
| 37 | >()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} | |
| 38 | ||
| 39 | export const make = Effect.gen(function* () { | |
| 40 | const resolver = yield* ConnectionResolver.ConnectionResolver; | |
| 41 | const sessions = yield* RpcSession.RpcSessionFactory; | |
| 42 | ||
| 43 | const connect = Effect.fn("ConnectionDriver.connect")(function* ( | |
| 44 | entry: ConnectionCatalogEntry, | |
| 45 | reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>, | |
| 46 | ) { | |
| 47 | const target = entry.target; | |
| 48 | yield* Effect.annotateCurrentSpan({ | |
| 49 | "connection.environment.id": target.environmentId, | |
| 50 | "connection.target.kind": target._tag, | |
| 62 | 51 | }); |
| 52 | yield* reportProgress({ stage: "preparing" }); | |
| 53 | const prepared = yield* resolver.prepare(entry); | |
| 54 | yield* reportProgress({ stage: "opening", prepared }); | |
| 55 | const session = yield* sessions.connect(prepared); | |
| 56 | yield* reportProgress({ stage: "synchronizing", prepared }); | |
| 57 | yield* session.ready; | |
| 58 | return { prepared, session } satisfies EnvironmentConnectionLease; | |
| 59 | }); | |
| 60 | ||
| 61 | return ConnectionDriver.of({ connect }); | |
| 62 | }); | |
| 63 | 63 | |
| 64 | return ConnectionDriver.of({ connect }); | |
| 65 | }), | |
| 66 | ); | |
| 64 | export const layer = Layer.effect(ConnectionDriver, make); |
packages/client-runtime/src/connection/errors.ts+17-17| ... | ... | @@ -11,14 +11,14 @@ import { |
| 11 | 11 | export function profileMissingError(connectionId: string): ConnectionBlockedError { |
| 12 | 12 | return new ConnectionBlockedError({ |
| 13 | 13 | reason: "configuration", |
| 14 | message: `Connection profile ${connectionId} is unavailable.`, | |
| 14 | detail: `Connection profile ${connectionId} is unavailable.`, | |
| 15 | 15 | }); |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | export function credentialMissingError(connectionId: string): ConnectionBlockedError { |
| 19 | 19 | return new ConnectionBlockedError({ |
| 20 | 20 | reason: "authentication", |
| 21 | message: `Connection credential ${connectionId} is unavailable.`, | |
| 21 | detail: `Connection credential ${connectionId} is unavailable.`, | |
| 22 | 22 | }); |
| 23 | 23 | } |
| 24 | 24 | |
| ... | ... | @@ -28,7 +28,7 @@ export function environmentMismatchError(input: { |
| 28 | 28 | }): ConnectionBlockedError { |
| 29 | 29 | return new ConnectionBlockedError({ |
| 30 | 30 | reason: "configuration", |
| 31 | message: `Connected environment ${input.actual} does not match ${input.expected}.`, | |
| 31 | detail: `Connected environment ${input.actual} does not match ${input.expected}.`, | |
| 32 | 32 | }); |
| 33 | 33 | } |
| 34 | 34 | |
| ... | ... | @@ -40,34 +40,34 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError |
| 40 | 40 | case "RelayAgentActivityPublishProofInvalidError": |
| 41 | 41 | return new ConnectionBlockedError({ |
| 42 | 42 | reason: "authentication", |
| 43 | message: error.message, | |
| 43 | detail: error.message, | |
| 44 | 44 | traceId: error.traceId, |
| 45 | 45 | }); |
| 46 | 46 | case "RelayEnvironmentConnectNotAuthorizedError": |
| 47 | 47 | case "RelayEnvironmentLinkProofInvalidError": |
| 48 | 48 | return new ConnectionBlockedError({ |
| 49 | 49 | reason: "permission", |
| 50 | message: error.message, | |
| 50 | detail: error.message, | |
| 51 | 51 | traceId: error.traceId, |
| 52 | 52 | }); |
| 53 | 53 | case "RelayEnvironmentEndpointTimedOutError": |
| 54 | 54 | return new ConnectionTransientError({ |
| 55 | 55 | reason: "timeout", |
| 56 | message: error.message, | |
| 56 | detail: error.message, | |
| 57 | 57 | traceId: error.traceId, |
| 58 | 58 | }); |
| 59 | 59 | case "RelayEnvironmentEndpointUnavailableError": |
| 60 | 60 | case "RelayEnvironmentLinkUnavailableError": |
| 61 | 61 | return new ConnectionTransientError({ |
| 62 | 62 | reason: "endpoint-unavailable", |
| 63 | message: error.message, | |
| 63 | detail: error.message, | |
| 64 | 64 | traceId: error.traceId, |
| 65 | 65 | }); |
| 66 | 66 | case "RelayEnvironmentLinkFailedError": |
| 67 | 67 | case "RelayInternalError": |
| 68 | 68 | return new ConnectionTransientError({ |
| 69 | 69 | reason: "relay-unavailable", |
| 70 | message: error.message, | |
| 70 | detail: error.message, | |
| 71 | 71 | traceId: error.traceId, |
| 72 | 72 | }); |
| 73 | 73 | } |
| ... | ... | @@ -80,13 +80,13 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection |
| 80 | 80 | if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { |
| 81 | 81 | return new ConnectionTransientError({ |
| 82 | 82 | reason: "timeout", |
| 83 | message: error.message, | |
| 83 | detail: error.message, | |
| 84 | 84 | ...(error.traceId ? { traceId: error.traceId } : {}), |
| 85 | 85 | }); |
| 86 | 86 | } |
| 87 | 87 | return new ConnectionTransientError({ |
| 88 | 88 | reason: "relay-unavailable", |
| 89 | message: error.message, | |
| 89 | detail: error.message, | |
| 90 | 90 | ...(error.traceId ? { traceId: error.traceId } : {}), |
| 91 | 91 | }); |
| 92 | 92 | } |
| ... | ... | @@ -98,43 +98,43 @@ export function mapRemoteEnvironmentError( |
| 98 | 98 | case "EnvironmentAuthInvalidError": |
| 99 | 99 | return new ConnectionBlockedError({ |
| 100 | 100 | reason: "authentication", |
| 101 | message: "The environment credential is invalid.", | |
| 101 | detail: "The environment credential is invalid.", | |
| 102 | 102 | traceId: error.traceId, |
| 103 | 103 | }); |
| 104 | 104 | case "EnvironmentScopeRequiredError": |
| 105 | 105 | case "EnvironmentOperationForbiddenError": |
| 106 | 106 | return new ConnectionBlockedError({ |
| 107 | 107 | reason: "permission", |
| 108 | message: "The environment credential does not grant the required access.", | |
| 108 | detail: "The environment credential does not grant the required access.", | |
| 109 | 109 | traceId: error.traceId, |
| 110 | 110 | }); |
| 111 | 111 | case "EnvironmentRequestInvalidError": |
| 112 | 112 | return new ConnectionBlockedError({ |
| 113 | 113 | reason: "configuration", |
| 114 | message: "The environment rejected the authentication request.", | |
| 114 | detail: "The environment rejected the authentication request.", | |
| 115 | 115 | traceId: error.traceId, |
| 116 | 116 | }); |
| 117 | 117 | case "RemoteEnvironmentAuthTimeoutError": |
| 118 | 118 | return new ConnectionTransientError({ |
| 119 | 119 | reason: "timeout", |
| 120 | message: error.message, | |
| 120 | detail: error.message, | |
| 121 | 121 | }); |
| 122 | 122 | case "RemoteEnvironmentAuthFetchError": |
| 123 | 123 | return new ConnectionTransientError({ |
| 124 | 124 | reason: "network", |
| 125 | message: error.message, | |
| 125 | detail: error.message, | |
| 126 | 126 | }); |
| 127 | 127 | case "EnvironmentInternalError": |
| 128 | 128 | return new ConnectionTransientError({ |
| 129 | 129 | reason: "remote-unavailable", |
| 130 | message: "The environment could not authorize the connection.", | |
| 130 | detail: "The environment could not authorize the connection.", | |
| 131 | 131 | traceId: error.traceId, |
| 132 | 132 | }); |
| 133 | 133 | case "RemoteEnvironmentAuthInvalidJsonError": |
| 134 | 134 | case "RemoteEnvironmentAuthUndeclaredStatusError": |
| 135 | 135 | return new ConnectionTransientError({ |
| 136 | 136 | reason: "remote-unavailable", |
| 137 | message: error.message, | |
| 137 | detail: error.message, | |
| 138 | 138 | }); |
| 139 | 139 | } |
| 140 | 140 | } |
packages/client-runtime/src/connection/index.ts+29-8| ... | ... | @@ -1,12 +1,33 @@ |
| 1 | 1 | export * from "./catalog.ts"; |
| 2 | export * from "./connectivity.ts"; | |
| 3 | export * from "./driver.ts"; | |
| 2 | export * as Connectivity from "./connectivity.ts"; | |
| 3 | export * as CredentialStore from "./credentialStore.ts"; | |
| 4 | export { | |
| 5 | ConnectionDriver, | |
| 6 | type ConnectionDriverProgress, | |
| 7 | type EnvironmentConnectionLease, | |
| 8 | } from "./driver.ts"; | |
| 4 | 9 | export * from "./errors.ts"; |
| 5 | export * from "./layer.ts"; | |
| 10 | export * as Connection from "./layer.ts"; | |
| 6 | 11 | export * from "./model.ts"; |
| 7 | export * from "./onboarding.ts"; | |
| 12 | export { | |
| 13 | type BearerConnectionUpdateInput, | |
| 14 | ConnectionOnboarding, | |
| 15 | type PairingConnectionInput, | |
| 16 | type SshConnectionInput, | |
| 17 | prepareBearerConnectionUpdate, | |
| 18 | preparePairingRegistration, | |
| 19 | prepareSshRegistration, | |
| 20 | registerPairingConnection, | |
| 21 | registerSshConnection, | |
| 22 | updateBearerConnection, | |
| 23 | } from "./onboarding.ts"; | |
| 8 | 24 | export * from "./presentation.ts"; |
| 9 | export * from "./registry.ts"; | |
| 10 | export * from "./resolver.ts"; | |
| 11 | export * from "./supervisor.ts"; | |
| 12 | export * from "./wakeups.ts"; | |
| 25 | export * as ProfileStore from "./profileStore.ts"; | |
| 26 | export { | |
| 27 | EnvironmentNotRegisteredError, | |
| 28 | EnvironmentRegistry, | |
| 29 | PlatformEnvironmentRemovalError, | |
| 30 | } from "./registry.ts"; | |
| 31 | export { ConnectionResolver } from "./resolver.ts"; | |
| 32 | export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts"; | |
| 33 | export * as Wakeups from "./wakeups.ts"; |
packages/client-runtime/src/connection/layer.ts+19-21| ... | ... | @@ -2,37 +2,37 @@ import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | import * as Stream from "effect/Stream"; |
| 4 | 4 | |
| 5 | import { connectionResolverLayer } from "./resolver.ts"; | |
| 6 | import { connectionDriverLayer } from "./driver.ts"; | |
| 7 | import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts"; | |
| 8 | import { connectionOnboardingLayer } from "./onboarding.ts"; | |
| 9 | import { PlatformConnectionSource } from "../platform/source.ts"; | |
| 10 | import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts"; | |
| 11 | import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts"; | |
| 12 | import { rpcSessionFactoryLayer } from "../rpc/session.ts"; | |
| 13 | ||
| 14 | const resolverLayer = connectionResolverLayer.pipe( | |
| 15 | Layer.provide(remoteEnvironmentAuthorizationLayer), | |
| 5 | import * as ConnectionResolver from "./resolver.ts"; | |
| 6 | import * as ConnectionDriver from "./driver.ts"; | |
| 7 | import * as EnvironmentRegistry from "./registry.ts"; | |
| 8 | import * as ConnectionOnboarding from "./onboarding.ts"; | |
| 9 | import * as PlatformConnectionSource from "../platform/source.ts"; | |
| 10 | import * as RelayEnvironmentDiscovery from "../relay/discovery.ts"; | |
| 11 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | |
| 12 | import * as RpcSession from "../rpc/session.ts"; | |
| 13 | ||
| 14 | const resolverLayer = ConnectionResolver.layer.pipe( | |
| 15 | Layer.provide(RemoteEnvironmentAuthorization.layer), | |
| 16 | 16 | ); |
| 17 | 17 | |
| 18 | const driverLayer = connectionDriverLayer.pipe( | |
| 19 | Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)), | |
| 18 | const driverLayer = ConnectionDriver.layer.pipe( | |
| 19 | Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), | |
| 20 | 20 | ); |
| 21 | 21 | |
| 22 | const registryLayer = environmentRegistryLayer.pipe(Layer.provide(driverLayer)); | |
| 22 | const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); | |
| 23 | 23 | |
| 24 | const onboardingLayer = connectionOnboardingLayer.pipe(Layer.provide(registryLayer)); | |
| 24 | const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); | |
| 25 | 25 | |
| 26 | 26 | const connectionServicesLayer = Layer.mergeAll( |
| 27 | 27 | registryLayer, |
| 28 | relayEnvironmentDiscoveryLayer, | |
| 28 | RelayEnvironmentDiscovery.layer, | |
| 29 | 29 | onboardingLayer, |
| 30 | 30 | ); |
| 31 | 31 | |
| 32 | 32 | const connectionStartupLayer = Layer.effectDiscard( |
| 33 | 33 | Effect.gen(function* () { |
| 34 | const registry = yield* EnvironmentRegistry; | |
| 35 | const platformSource = yield* PlatformConnectionSource; | |
| 34 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 35 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; | |
| 36 | 36 | yield* registry.start; |
| 37 | 37 | yield* platformSource.registrations.pipe( |
| 38 | 38 | Stream.runForEach(registry.registerPlatform), |
| ... | ... | @@ -41,6 +41,4 @@ const connectionStartupLayer = Layer.effectDiscard( |
| 41 | 41 | }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), |
| 42 | 42 | ); |
| 43 | 43 | |
| 44 | export const connectionLayer = connectionStartupLayer.pipe( | |
| 45 | Layer.provideMerge(connectionServicesLayer), | |
| 46 | ); | |
| 44 | export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); |
packages/client-runtime/src/connection/model.ts+31-26| ... | ... | @@ -57,44 +57,49 @@ export type ConnectionTargetKind = ConnectionTarget["_tag"]; |
| 57 | 57 | |
| 58 | 58 | export type NetworkStatus = "unknown" | "offline" | "online"; |
| 59 | 59 | |
| 60 | export type ConnectionTransientReason = | |
| 61 | | "network" | |
| 62 | | "timeout" | |
| 63 | | "transport" | |
| 64 | | "endpoint-unavailable" | |
| 65 | | "relay-unavailable" | |
| 66 | | "remote-unavailable"; | |
| 67 | ||
| 68 | export type ConnectionBlockedReason = | |
| 69 | | "authentication" | |
| 70 | | "configuration" | |
| 71 | | "permission" | |
| 72 | | "unsupported"; | |
| 60 | export const ConnectionTransientReason = Schema.Literals([ | |
| 61 | "network", | |
| 62 | "timeout", | |
| 63 | "transport", | |
| 64 | "endpoint-unavailable", | |
| 65 | "relay-unavailable", | |
| 66 | "remote-unavailable", | |
| 67 | ]); | |
| 68 | export type ConnectionTransientReason = typeof ConnectionTransientReason.Type; | |
| 69 | ||
| 70 | export const ConnectionBlockedReason = Schema.Literals([ | |
| 71 | "authentication", | |
| 72 | "configuration", | |
| 73 | "permission", | |
| 74 | "unsupported", | |
| 75 | ]); | |
| 76 | export type ConnectionBlockedReason = typeof ConnectionBlockedReason.Type; | |
| 73 | 77 | |
| 74 | 78 | export class ConnectionTransientError extends Schema.TaggedErrorClass<ConnectionTransientError>()( |
| 75 | 79 | "ConnectionTransientError", |
| 76 | 80 | { |
| 77 | reason: Schema.Literals([ | |
| 78 | "network", | |
| 79 | "timeout", | |
| 80 | "transport", | |
| 81 | "endpoint-unavailable", | |
| 82 | "relay-unavailable", | |
| 83 | "remote-unavailable", | |
| 84 | ]), | |
| 85 | message: Schema.String, | |
| 81 | reason: ConnectionTransientReason, | |
| 82 | detail: Schema.String, | |
| 86 | 83 | traceId: Schema.optionalKey(Schema.String), |
| 87 | 84 | }, |
| 88 | ) {} | |
| 85 | ) { | |
| 86 | override get message(): string { | |
| 87 | return this.detail; | |
| 88 | } | |
| 89 | } | |
| 89 | 90 | |
| 90 | 91 | export class ConnectionBlockedError extends Schema.TaggedErrorClass<ConnectionBlockedError>()( |
| 91 | 92 | "ConnectionBlockedError", |
| 92 | 93 | { |
| 93 | reason: Schema.Literals(["authentication", "configuration", "permission", "unsupported"]), | |
| 94 | message: Schema.String, | |
| 94 | reason: ConnectionBlockedReason, | |
| 95 | detail: Schema.String, | |
| 95 | 96 | traceId: Schema.optionalKey(Schema.String), |
| 96 | 97 | }, |
| 97 | ) {} | |
| 98 | ) { | |
| 99 | override get message(): string { | |
| 100 | return this.detail; | |
| 101 | } | |
| 102 | } | |
| 98 | 103 | |
| 99 | 104 | export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; |
| 100 | 105 |
packages/client-runtime/src/connection/onboarding.ts+52-47| ... | ... | @@ -6,22 +6,22 @@ import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import * as Schema from "effect/Schema"; |
| 8 | 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 9 | import { HttpClient } from "effect/unstable/http"; | |
| 9 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 10 | 10 | |
| 11 | 11 | import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; |
| 12 | 12 | import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; |
| 13 | 13 | import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; |
| 14 | import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; | |
| 14 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 15 | 15 | import { |
| 16 | 16 | BearerConnectionCredential, |
| 17 | 17 | BearerConnectionProfile, |
| 18 | 18 | BearerConnectionRegistration, |
| 19 | 19 | type ConnectionCatalogEntry, |
| 20 | 20 | type ConnectionCredential, |
| 21 | ConnectionCredentialStore, | |
| 22 | 21 | SshConnectionProfile, |
| 23 | 22 | SshConnectionRegistration, |
| 24 | 23 | } from "./catalog.ts"; |
| 24 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | |
| 25 | 25 | import { mapRemoteEnvironmentError } from "./errors.ts"; |
| 26 | 26 | import { |
| 27 | 27 | BearerConnectionTarget, |
| ... | ... | @@ -29,8 +29,8 @@ import { |
| 29 | 29 | SshConnectionTarget, |
| 30 | 30 | type ConnectionAttemptError, |
| 31 | 31 | } from "./model.ts"; |
| 32 | import type { ConnectionPersistenceError } from "../platform/persistence.ts"; | |
| 33 | import { EnvironmentRegistry } from "./registry.ts"; | |
| 32 | import * as Persistence from "../platform/persistence.ts"; | |
| 33 | import * as EnvironmentRegistry from "./registry.ts"; | |
| 34 | 34 | |
| 35 | 35 | export interface PairingConnectionInput { |
| 36 | 36 | readonly pairingUrl?: string; |
| ... | ... | @@ -54,13 +54,19 @@ export class ConnectionOnboarding extends Context.Service< |
| 54 | 54 | { |
| 55 | 55 | readonly registerPairing: ( |
| 56 | 56 | input: PairingConnectionInput, |
| 57 | ) => Effect.Effect<EnvironmentId, ConnectionAttemptError | ConnectionPersistenceError>; | |
| 57 | ) => Effect.Effect< | |
| 58 | EnvironmentId, | |
| 59 | ConnectionAttemptError | Persistence.ConnectionPersistenceError | |
| 60 | >; | |
| 58 | 61 | readonly registerSsh: ( |
| 59 | 62 | input: SshConnectionInput, |
| 60 | ) => Effect.Effect<EnvironmentId, ConnectionAttemptError | ConnectionPersistenceError>; | |
| 63 | ) => Effect.Effect< | |
| 64 | EnvironmentId, | |
| 65 | ConnectionAttemptError | Persistence.ConnectionPersistenceError | |
| 66 | >; | |
| 61 | 67 | readonly updateBearer: ( |
| 62 | 68 | input: BearerConnectionUpdateInput, |
| 63 | ) => Effect.Effect<void, ConnectionAttemptError | ConnectionPersistenceError>; | |
| 69 | ) => Effect.Effect<void, ConnectionAttemptError | Persistence.ConnectionPersistenceError>; | |
| 64 | 70 | } |
| 65 | 71 | >()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} |
| 66 | 72 | |
| ... | ... | @@ -71,7 +77,7 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso |
| 71 | 77 | catch: (cause) => |
| 72 | 78 | new ConnectionBlockedError({ |
| 73 | 79 | reason: "configuration", |
| 74 | message: cause instanceof Error ? cause.message : "The pairing details are invalid.", | |
| 80 | detail: cause instanceof Error ? cause.message : "The pairing details are invalid.", | |
| 75 | 81 | }), |
| 76 | 82 | }); |
| 77 | 83 | }, |
| ... | ... | @@ -81,7 +87,7 @@ export const preparePairingRegistration = Effect.fn( |
| 81 | 87 | "clientRuntime.connection.onboarding.preparePairingRegistration", |
| 82 | 88 | )(function* (input: PairingConnectionInput) { |
| 83 | 89 | const target = yield* resolvePairingTarget(input); |
| 84 | const presentation = yield* ClientPresentation; | |
| 90 | const presentation = yield* ClientCapabilities.ClientPresentation; | |
| 85 | 91 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ |
| 86 | 92 | httpBaseUrl: target.httpBaseUrl, |
| 87 | 93 | }).pipe(Effect.mapError(mapRemoteEnvironmentError)); |
| ... | ... | @@ -116,7 +122,7 @@ export const registerPairingConnection = Effect.fn( |
| 116 | 122 | "clientRuntime.connection.onboarding.registerPairingConnection", |
| 117 | 123 | )(function* (input: PairingConnectionInput) { |
| 118 | 124 | const registration = yield* preparePairingRegistration(input); |
| 119 | const registry = yield* EnvironmentRegistry; | |
| 125 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 120 | 126 | yield* registry.register(registration); |
| 121 | 127 | return registration.target.environmentId; |
| 122 | 128 | }); |
| ... | ... | @@ -127,8 +133,8 @@ const isBearerProfile = Schema.is(BearerConnectionProfile); |
| 127 | 133 | export const updateBearerConnection = Effect.fn( |
| 128 | 134 | "clientRuntime.connection.onboarding.updateBearerConnection", |
| 129 | 135 | )(function* (input: BearerConnectionUpdateInput) { |
| 130 | const registry = yield* EnvironmentRegistry; | |
| 131 | const credentials = yield* ConnectionCredentialStore; | |
| 136 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 137 | const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; | |
| 132 | 138 | const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); |
| 133 | 139 | const credential = |
| 134 | 140 | entry?.target._tag === "BearerConnectionTarget" |
| ... | ... | @@ -159,7 +165,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( |
| 159 | 165 | ) { |
| 160 | 166 | return yield* new ConnectionBlockedError({ |
| 161 | 167 | reason: "configuration", |
| 162 | message: "Only saved bearer environments can be edited.", | |
| 168 | detail: "Only saved bearer environments can be edited.", | |
| 163 | 169 | }); |
| 164 | 170 | } |
| 165 | 171 | |
| ... | ... | @@ -167,7 +173,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( |
| 167 | 173 | if (Option.isNone(credential) || !isBearerCredential(credential.value)) { |
| 168 | 174 | return yield* new ConnectionBlockedError({ |
| 169 | 175 | reason: "authentication", |
| 170 | message: "The saved bearer credential is unavailable.", | |
| 176 | detail: "The saved bearer credential is unavailable.", | |
| 171 | 177 | }); |
| 172 | 178 | } |
| 173 | 179 | |
| ... | ... | @@ -175,7 +181,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( |
| 175 | 181 | if (label === "") { |
| 176 | 182 | return yield* new ConnectionBlockedError({ |
| 177 | 183 | reason: "configuration", |
| 178 | message: "Environment label cannot be empty.", | |
| 184 | detail: "Environment label cannot be empty.", | |
| 179 | 185 | }); |
| 180 | 186 | } |
| 181 | 187 | const httpBaseUrl = yield* Effect.try({ |
| ... | ... | @@ -183,7 +189,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( |
| 183 | 189 | catch: (cause) => |
| 184 | 190 | new ConnectionBlockedError({ |
| 185 | 191 | reason: "configuration", |
| 186 | message: cause instanceof Error ? cause.message : "The environment URL is invalid.", | |
| 192 | detail: cause instanceof Error ? cause.message : "The environment URL is invalid.", | |
| 187 | 193 | }), |
| 188 | 194 | }); |
| 189 | 195 | const connectionId = entry.target.connectionId; |
| ... | ... | @@ -207,7 +213,7 @@ export const prepareBearerConnectionUpdate = Effect.fn( |
| 207 | 213 | export const prepareSshRegistration = Effect.fn( |
| 208 | 214 | "clientRuntime.connection.onboarding.prepareSshRegistration", |
| 209 | 215 | )(function* (input: SshConnectionInput) { |
| 210 | const gateway = yield* SshEnvironmentGateway; | |
| 216 | const gateway = yield* ClientCapabilities.SshEnvironmentGateway; | |
| 211 | 217 | const provisioned = yield* gateway.provision(input.target); |
| 212 | 218 | const connectionId = `ssh:${provisioned.environmentId}`; |
| 213 | 219 | const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias; |
| ... | ... | @@ -231,37 +237,36 @@ export const registerSshConnection = Effect.fn( |
| 231 | 237 | "clientRuntime.connection.onboarding.registerSshConnection", |
| 232 | 238 | )(function* (input: SshConnectionInput) { |
| 233 | 239 | const registration = yield* prepareSshRegistration(input); |
| 234 | const registry = yield* EnvironmentRegistry; | |
| 240 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 235 | 241 | yield* registry.register(registration); |
| 236 | 242 | return registration.target.environmentId; |
| 237 | 243 | }); |
| 238 | 244 | |
| 239 | export const connectionOnboardingLayer = Layer.effect( | |
| 240 | ConnectionOnboarding, | |
| 241 | Effect.gen(function* () { | |
| 242 | const registry = yield* EnvironmentRegistry; | |
| 243 | const presentation = yield* ClientPresentation; | |
| 244 | const httpClient = yield* HttpClient.HttpClient; | |
| 245 | const ssh = yield* SshEnvironmentGateway; | |
| 246 | const credentials = yield* ConnectionCredentialStore; | |
| 245 | export const make = Effect.gen(function* () { | |
| 246 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 247 | const presentation = yield* ClientCapabilities.ClientPresentation; | |
| 248 | const httpClient = yield* HttpClient.HttpClient; | |
| 249 | const ssh = yield* ClientCapabilities.SshEnvironmentGateway; | |
| 250 | const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; | |
| 247 | 251 | |
| 248 | return ConnectionOnboarding.of({ | |
| 249 | registerPairing: (input) => | |
| 250 | registerPairingConnection(input).pipe( | |
| 251 | Effect.provideService(EnvironmentRegistry, registry), | |
| 252 | Effect.provideService(ClientPresentation, presentation), | |
| 253 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 254 | ), | |
| 255 | registerSsh: (input) => | |
| 256 | registerSshConnection(input).pipe( | |
| 257 | Effect.provideService(EnvironmentRegistry, registry), | |
| 258 | Effect.provideService(SshEnvironmentGateway, ssh), | |
| 259 | ), | |
| 260 | updateBearer: (input) => | |
| 261 | updateBearerConnection(input).pipe( | |
| 262 | Effect.provideService(EnvironmentRegistry, registry), | |
| 263 | Effect.provideService(ConnectionCredentialStore, credentials), | |
| 264 | ), | |
| 265 | }); | |
| 266 | }), | |
| 267 | ); | |
| 252 | return ConnectionOnboarding.of({ | |
| 253 | registerPairing: (input) => | |
| 254 | registerPairingConnection(input).pipe( | |
| 255 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), | |
| 256 | Effect.provideService(ClientCapabilities.ClientPresentation, presentation), | |
| 257 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 258 | ), | |
| 259 | registerSsh: (input) => | |
| 260 | registerSshConnection(input).pipe( | |
| 261 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), | |
| 262 | Effect.provideService(ClientCapabilities.SshEnvironmentGateway, ssh), | |
| 263 | ), | |
| 264 | updateBearer: (input) => | |
| 265 | updateBearerConnection(input).pipe( | |
| 266 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), | |
| 267 | Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials), | |
| 268 | ), | |
| 269 | }); | |
| 270 | }); | |
| 271 | ||
| 272 | export const layer = Layer.effect(ConnectionOnboarding, make); |
packages/client-runtime/src/connection/presentation.test.ts+3-3| ... | ... | @@ -67,7 +67,7 @@ describe("connection presentation", () => { |
| 67 | 67 | attempt: 2, |
| 68 | 68 | lastFailure: new ConnectionTransientError({ |
| 69 | 69 | reason: "transport", |
| 70 | message: "Socket closed.", | |
| 70 | detail: "Socket closed.", | |
| 71 | 71 | traceId: "trace-previous", |
| 72 | 72 | }), |
| 73 | 73 | }), |
| ... | ... | @@ -85,7 +85,7 @@ describe("connection presentation", () => { |
| 85 | 85 | retryAt: 1, |
| 86 | 86 | lastFailure: new ConnectionTransientError({ |
| 87 | 87 | reason: "transport", |
| 88 | message: "Disconnected.", | |
| 88 | detail: "Disconnected.", | |
| 89 | 89 | traceId: "trace-1", |
| 90 | 90 | }), |
| 91 | 91 | }), |
| ... | ... | @@ -106,7 +106,7 @@ describe("connection presentation", () => { |
| 106 | 106 | attempt: 2, |
| 107 | 107 | lastFailure: new ConnectionTransientError({ |
| 108 | 108 | reason: "transport", |
| 109 | message: "Relay connection timed out.", | |
| 109 | detail: "Relay connection timed out.", | |
| 110 | 110 | traceId: "trace-retry", |
| 111 | 111 | }), |
| 112 | 112 | }), |
packages/client-runtime/src/connection/profileStore.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import type * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import type * as Option from "effect/Option"; | |
| 5 | ||
| 6 | import type { ConnectionProfile } from "./catalog.ts"; | |
| 7 | import type { ConnectionAttemptError } from "./model.ts"; | |
| 8 | ||
| 9 | export class ConnectionProfileStore extends Context.Service< | |
| 10 | ConnectionProfileStore, | |
| 11 | { | |
| 12 | readonly get: ( | |
| 13 | connectionId: string, | |
| 14 | ) => Effect.Effect<Option.Option<ConnectionProfile>, ConnectionAttemptError>; | |
| 15 | readonly put: (profile: ConnectionProfile) => Effect.Effect<void, ConnectionAttemptError>; | |
| 16 | readonly remove: (connectionId: string) => Effect.Effect<void, ConnectionAttemptError>; | |
| 17 | } | |
| 18 | >()("@t3tools/client-runtime/connection/profileStore/ConnectionProfileStore") {} | |
| 19 | ||
| 20 | export const make = (service: ConnectionProfileStore["Service"]) => | |
| 21 | ConnectionProfileStore.of(service); | |
| 22 | ||
| 23 | export const layer = (service: ConnectionProfileStore["Service"]) => | |
| 24 | Layer.succeed(ConnectionProfileStore, make(service)); |
packages/client-runtime/src/connection/registry.test.ts+63-66| ... | ... | @@ -14,23 +14,22 @@ import * as Result from "effect/Result"; |
| 14 | 14 | import * as Stream from "effect/Stream"; |
| 15 | 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 16 | 16 | |
| 17 | import { SshEnvironmentGateway } from "../platform/capabilities.ts"; | |
| 18 | import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts"; | |
| 17 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 18 | import * as TokenStore from "../authorization/tokenStore.ts"; | |
| 19 | 19 | import { |
| 20 | 20 | BearerConnectionCredential, |
| 21 | 21 | BearerConnectionProfile, |
| 22 | 22 | BearerConnectionRegistration, |
| 23 | 23 | type ConnectionRegistration, |
| 24 | ConnectionCredentialStore, | |
| 25 | ConnectionProfileStore, | |
| 26 | 24 | PrimaryConnectionRegistration, |
| 27 | 25 | RelayConnectionRegistration, |
| 28 | 26 | SshConnectionProfile, |
| 29 | 27 | type ConnectionCredential, |
| 30 | 28 | type ConnectionProfile, |
| 31 | 29 | } from "./catalog.ts"; |
| 32 | import { Connectivity } from "./connectivity.ts"; | |
| 33 | import { ConnectionDriver } from "./driver.ts"; | |
| 30 | import * as Connectivity from "./connectivity.ts"; | |
| 31 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | |
| 32 | import * as ConnectionDriver from "./driver.ts"; | |
| 34 | 33 | import { |
| 35 | 34 | ConnectionTransientError, |
| 36 | 35 | BearerConnectionTarget, |
| ... | ... | @@ -41,17 +40,12 @@ import { |
| 41 | 40 | type PreparedConnection, |
| 42 | 41 | type SupervisorConnectionState, |
| 43 | 42 | } from "./model.ts"; |
| 44 | import { | |
| 45 | ConnectionPersistenceError, | |
| 46 | ConnectionRegistrationStore, | |
| 47 | ConnectionTargetStore, | |
| 48 | EnvironmentCacheStore, | |
| 49 | EnvironmentOwnedDataCleanup, | |
| 50 | } from "../platform/persistence.ts"; | |
| 51 | import { EnvironmentRegistry, environmentRegistryLayer } from "./registry.ts"; | |
| 52 | import type { RpcSession } from "../rpc/session.ts"; | |
| 53 | import { EnvironmentSupervisor } from "./supervisor.ts"; | |
| 54 | import { ConnectionWakeups } from "./wakeups.ts"; | |
| 43 | import * as Persistence from "../platform/persistence.ts"; | |
| 44 | import * as ConnectionProfileStore from "./profileStore.ts"; | |
| 45 | import * as EnvironmentRegistry from "./registry.ts"; | |
| 46 | import * as RpcSession from "../rpc/session.ts"; | |
| 47 | import * as EnvironmentSupervisor from "./supervisor.ts"; | |
| 48 | import * as ConnectionWakeups from "./wakeups.ts"; | |
| 55 | 49 | |
| 56 | 50 | const TARGET = new PrimaryConnectionTarget({ |
| 57 | 51 | environmentId: EnvironmentId.make("environment-1"), |
| ... | ... | @@ -137,10 +131,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 137 | 131 | readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect<void>; |
| 138 | 132 | readonly beforeRegistrationRegister?: ( |
| 139 | 133 | registration: ConnectionRegistration, |
| 140 | ) => Effect.Effect<void, ConnectionPersistenceError>; | |
| 134 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; | |
| 141 | 135 | readonly beforeRegistrationRemove?: ( |
| 142 | 136 | target: ConnectionTarget, |
| 143 | ) => Effect.Effect<void, ConnectionPersistenceError>; | |
| 137 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; | |
| 144 | 138 | }, |
| 145 | 139 | ) { |
| 146 | 140 | const storedTargets = yield* Ref.make( |
| ... | ... | @@ -160,7 +154,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 160 | 154 | new Map([ |
| 161 | 155 | [ |
| 162 | 156 | SSH_CONNECTION.environmentId, |
| 163 | new RemoteDpopAccessToken({ | |
| 157 | new TokenStore.RemoteDpopAccessToken({ | |
| 164 | 158 | environmentId: SSH_CONNECTION.environmentId, |
| 165 | 159 | label: SSH_CONNECTION.label, |
| 166 | 160 | endpoint: { |
| ... | ... | @@ -177,10 +171,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 177 | 171 | ); |
| 178 | 172 | const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]); |
| 179 | 173 | |
| 180 | const targetStore = ConnectionTargetStore.of({ | |
| 174 | const targetStore = Persistence.ConnectionTargetStore.of({ | |
| 181 | 175 | list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), |
| 182 | 176 | }); |
| 183 | const registrationStore = ConnectionRegistrationStore.of({ | |
| 177 | const registrationStore = Persistence.ConnectionRegistrationStore.of({ | |
| 184 | 178 | register: (registration) => |
| 185 | 179 | Effect.gen(function* () { |
| 186 | 180 | yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; |
| ... | ... | @@ -239,7 +233,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 239 | 233 | }); |
| 240 | 234 | }), |
| 241 | 235 | }); |
| 242 | const cacheStore = EnvironmentCacheStore.of({ | |
| 236 | const cacheStore = Persistence.EnvironmentCacheStore.of({ | |
| 243 | 237 | loadShell: (environmentId) => |
| 244 | 238 | Ref.get(shellCache).pipe( |
| 245 | 239 | Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))), |
| ... | ... | @@ -264,16 +258,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 264 | 258 | ), |
| 265 | 259 | ), |
| 266 | 260 | }); |
| 267 | const ownedDataCleanup = EnvironmentOwnedDataCleanup.of({ | |
| 261 | const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({ | |
| 268 | 262 | clear: (environmentId) => |
| 269 | 263 | Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), |
| 270 | 264 | }); |
| 271 | 265 | const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online"); |
| 272 | const connectivity = Connectivity.of({ | |
| 266 | const connectivity = Connectivity.Connectivity.of({ | |
| 273 | 267 | status: SubscriptionRef.get(networkStatus), |
| 274 | 268 | changes: SubscriptionRef.changes(networkStatus), |
| 275 | 269 | }); |
| 276 | const profileStore = ConnectionProfileStore.of({ | |
| 270 | const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ | |
| 277 | 271 | get: (connectionId) => |
| 278 | 272 | Ref.update(profileReadCount, (count) => count + 1).pipe( |
| 279 | 273 | Effect.andThen(Ref.get(storedProfiles)), |
| ... | ... | @@ -292,7 +286,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 292 | 286 | return next; |
| 293 | 287 | }), |
| 294 | 288 | }); |
| 295 | const credentialStore = ConnectionCredentialStore.of({ | |
| 289 | const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ | |
| 296 | 290 | get: (connectionId) => |
| 297 | 291 | Ref.get(storedCredentials).pipe( |
| 298 | 292 | Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), |
| ... | ... | @@ -310,7 +304,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 310 | 304 | return next; |
| 311 | 305 | }), |
| 312 | 306 | }); |
| 313 | const tokenStore = RemoteDpopAccessTokenStore.of({ | |
| 307 | const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ | |
| 314 | 308 | get: (environmentId) => |
| 315 | 309 | Ref.get(storedRemoteTokens).pipe( |
| 316 | 310 | Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), |
| ... | ... | @@ -328,12 +322,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 328 | 322 | return next; |
| 329 | 323 | }), |
| 330 | 324 | }); |
| 331 | const sshGateway = SshEnvironmentGateway.of({ | |
| 325 | const sshGateway = ClientCapabilities.SshEnvironmentGateway.of({ | |
| 332 | 326 | provision: () => Effect.die(new Error("SSH provisioning is not used.")), |
| 333 | 327 | prepare: () => Effect.die(new Error("SSH preparation is not used.")), |
| 334 | 328 | disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), |
| 335 | 329 | }); |
| 336 | const driver = ConnectionDriver.of({ | |
| 330 | const driver = ConnectionDriver.ConnectionDriver.of({ | |
| 337 | 331 | connect: (entry, reportProgress) => |
| 338 | 332 | Effect.gen(function* () { |
| 339 | 333 | const target = entry.target; |
| ... | ... | @@ -350,12 +344,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 350 | 344 | yield* Ref.update(sessions, (current) => [...current, { closed }]); |
| 351 | 345 | const session = yield* Effect.acquireRelease( |
| 352 | 346 | Effect.succeed({ |
| 353 | client: {} as RpcSession["client"], | |
| 347 | client: {} as RpcSession.RpcSession["client"], | |
| 354 | 348 | initialConfig: Effect.die(new Error("Config is not used by registry tests.")), |
| 355 | 349 | ready: Effect.void, |
| 356 | 350 | probe: Effect.void, |
| 357 | 351 | closed: Deferred.await(closed), |
| 358 | } satisfies RpcSession), | |
| 352 | } satisfies RpcSession.RpcSession), | |
| 359 | 353 | () => Ref.update(releasedSessions, (count) => count + 1), |
| 360 | 354 | ); |
| 361 | 355 | yield* reportProgress({ stage: "synchronizing", prepared }); |
| ... | ... | @@ -364,21 +358,24 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 364 | 358 | }), |
| 365 | 359 | }); |
| 366 | 360 | |
| 367 | const cacheLayer = Layer.succeed(EnvironmentCacheStore, cacheStore); | |
| 368 | const layer = environmentRegistryLayer.pipe( | |
| 361 | const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore); | |
| 362 | const layer = EnvironmentRegistry.layer.pipe( | |
| 369 | 363 | Layer.provide( |
| 370 | 364 | Layer.mergeAll( |
| 371 | Layer.succeed(ConnectionTargetStore, targetStore), | |
| 372 | Layer.succeed(ConnectionRegistrationStore, registrationStore), | |
| 373 | Layer.succeed(ConnectionProfileStore, profileStore), | |
| 374 | Layer.succeed(ConnectionCredentialStore, credentialStore), | |
| 375 | Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), | |
| 376 | Layer.succeed(SshEnvironmentGateway, sshGateway), | |
| 377 | Layer.succeed(Connectivity, connectivity), | |
| 378 | Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), | |
| 379 | Layer.succeed(ConnectionDriver, driver), | |
| 365 | Layer.succeed(Persistence.ConnectionTargetStore, targetStore), | |
| 366 | Layer.succeed(Persistence.ConnectionRegistrationStore, registrationStore), | |
| 367 | Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), | |
| 368 | Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), | |
| 369 | Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), | |
| 370 | Layer.succeed(ClientCapabilities.SshEnvironmentGateway, sshGateway), | |
| 371 | Layer.succeed(Connectivity.Connectivity, connectivity), | |
| 372 | Layer.succeed( | |
| 373 | ConnectionWakeups.ConnectionWakeups, | |
| 374 | ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), | |
| 375 | ), | |
| 376 | Layer.succeed(ConnectionDriver.ConnectionDriver, driver), | |
| 380 | 377 | cacheLayer, |
| 381 | Layer.succeed(EnvironmentOwnedDataCleanup, ownedDataCleanup), | |
| 378 | Layer.succeed(Persistence.EnvironmentOwnedDataCleanup, ownedDataCleanup), | |
| 382 | 379 | ), |
| 383 | 380 | ), |
| 384 | 381 | ); |
| ... | ... | @@ -401,7 +398,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 401 | 398 | }); |
| 402 | 399 | |
| 403 | 400 | function awaitConnectionState( |
| 404 | registry: EnvironmentRegistry["Service"], | |
| 401 | registry: EnvironmentRegistry.EnvironmentRegistry["Service"], | |
| 405 | 402 | environmentId: EnvironmentId, |
| 406 | 403 | predicate: (state: SupervisorConnectionState) => boolean, |
| 407 | 404 | ) { |
| ... | ... | @@ -422,7 +419,7 @@ describe("EnvironmentRegistry", () => { |
| 422 | 419 | const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); |
| 423 | 420 | |
| 424 | 421 | yield* Effect.gen(function* () { |
| 425 | const registry = yield* EnvironmentRegistry; | |
| 422 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 426 | 423 | const entry = (yield* SubscriptionRef.get(registry.entries)).get( |
| 427 | 424 | SSH_CONNECTION.environmentId, |
| 428 | 425 | ); |
| ... | ... | @@ -438,7 +435,7 @@ describe("EnvironmentRegistry", () => { |
| 438 | 435 | const harness = yield* makeHarness([]); |
| 439 | 436 | |
| 440 | 437 | yield* Effect.gen(function* () { |
| 441 | const registry = yield* EnvironmentRegistry; | |
| 438 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 442 | 439 | const offline = yield* Effect.forkChild( |
| 443 | 440 | SubscriptionRef.changes(registry.networkStatus).pipe( |
| 444 | 441 | Stream.filter((status) => status === "offline"), |
| ... | ... | @@ -471,7 +468,7 @@ describe("EnvironmentRegistry", () => { |
| 471 | 468 | }); |
| 472 | 469 | |
| 473 | 470 | yield* Effect.gen(function* () { |
| 474 | const registry = yield* EnvironmentRegistry; | |
| 471 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 475 | 472 | const start = yield* Effect.forkChild(registry.start); |
| 476 | 473 | |
| 477 | 474 | yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second")); |
| ... | ... | @@ -487,7 +484,7 @@ describe("EnvironmentRegistry", () => { |
| 487 | 484 | Effect.gen(function* () { |
| 488 | 485 | const harness = yield* makeHarness([TARGET]); |
| 489 | 486 | yield* Effect.gen(function* () { |
| 490 | const registry = yield* EnvironmentRegistry; | |
| 487 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 491 | 488 | yield* registry.start; |
| 492 | 489 | yield* awaitConnectionState( |
| 493 | 490 | registry, |
| ... | ... | @@ -499,7 +496,7 @@ describe("EnvironmentRegistry", () => { |
| 499 | 496 | .runStream( |
| 500 | 497 | TARGET.environmentId, |
| 501 | 498 | Stream.unwrap( |
| 502 | EnvironmentSupervisor.pipe( | |
| 499 | EnvironmentSupervisor.EnvironmentSupervisor.pipe( | |
| 503 | 500 | Effect.map((supervisor) => |
| 504 | 501 | Stream.concat( |
| 505 | 502 | Stream.fromEffect(SubscriptionRef.get(supervisor.state)), |
| ... | ... | @@ -527,7 +524,7 @@ describe("EnvironmentRegistry", () => { |
| 527 | 524 | Effect.gen(function* () { |
| 528 | 525 | const harness = yield* makeHarness([TARGET]); |
| 529 | 526 | yield* Effect.gen(function* () { |
| 530 | const registry = yield* EnvironmentRegistry; | |
| 527 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 531 | 528 | yield* registry.start; |
| 532 | 529 | yield* awaitConnectionState( |
| 533 | 530 | registry, |
| ... | ... | @@ -554,7 +551,7 @@ describe("EnvironmentRegistry", () => { |
| 554 | 551 | active!.closed, |
| 555 | 552 | new ConnectionTransientError({ |
| 556 | 553 | reason: "transport", |
| 557 | message: "Disconnected.", | |
| 554 | detail: "Disconnected.", | |
| 558 | 555 | }), |
| 559 | 556 | ); |
| 560 | 557 | yield* Fiber.join(retryFiber); |
| ... | ... | @@ -578,7 +575,7 @@ describe("EnvironmentRegistry", () => { |
| 578 | 575 | const harness = yield* makeHarness([]); |
| 579 | 576 | |
| 580 | 577 | yield* Effect.gen(function* () { |
| 581 | const registry = yield* EnvironmentRegistry; | |
| 578 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 582 | 579 | yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); |
| 583 | 580 | yield* awaitConnectionState( |
| 584 | 581 | registry, |
| ... | ... | @@ -603,7 +600,7 @@ describe("EnvironmentRegistry", () => { |
| 603 | 600 | const harness = yield* makeHarness([RELAY_TARGET]); |
| 604 | 601 | |
| 605 | 602 | yield* Effect.gen(function* () { |
| 606 | const registry = yield* EnvironmentRegistry; | |
| 603 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 607 | 604 | const firstObserved = yield* Deferred.make<void>(); |
| 608 | 605 | const secondObserved = yield* Deferred.make<void>(); |
| 609 | 606 | const labels = yield* Ref.make<ReadonlyArray<string>>([]); |
| ... | ... | @@ -619,7 +616,7 @@ describe("EnvironmentRegistry", () => { |
| 619 | 616 | .followStream( |
| 620 | 617 | RELAY_TARGET.environmentId, |
| 621 | 618 | Stream.unwrap( |
| 622 | EnvironmentSupervisor.pipe( | |
| 619 | EnvironmentSupervisor.EnvironmentSupervisor.pipe( | |
| 623 | 620 | Effect.map((supervisor) => |
| 624 | 621 | Stream.concat(Stream.succeed(supervisor.target.label), Stream.never), |
| 625 | 622 | ), |
| ... | ... | @@ -655,7 +652,7 @@ describe("EnvironmentRegistry", () => { |
| 655 | 652 | const harness = yield* makeHarness([]); |
| 656 | 653 | |
| 657 | 654 | yield* Effect.gen(function* () { |
| 658 | const registry = yield* EnvironmentRegistry; | |
| 655 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 659 | 656 | yield* registry.retryNow(EnvironmentId.make("removed-environment")); |
| 660 | 657 | }).pipe(Effect.provide(harness.layer), Effect.scoped); |
| 661 | 658 | }), |
| ... | ... | @@ -670,7 +667,7 @@ describe("EnvironmentRegistry", () => { |
| 670 | 667 | ); |
| 671 | 668 | |
| 672 | 669 | yield* Effect.gen(function* () { |
| 673 | const registry = yield* EnvironmentRegistry; | |
| 670 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 674 | 671 | yield* registry.removeRelayEnvironments(); |
| 675 | 672 | |
| 676 | 673 | const targets = yield* Ref.get(harness.storedTargets); |
| ... | ... | @@ -695,7 +692,7 @@ describe("EnvironmentRegistry", () => { |
| 695 | 692 | const harness = yield* makeHarness([RELAY_TARGET], [], [], { |
| 696 | 693 | beforeRegistrationRemove: () => |
| 697 | 694 | Effect.fail( |
| 698 | new ConnectionPersistenceError({ | |
| 695 | new Persistence.ConnectionPersistenceError({ | |
| 699 | 696 | operation: "remove-connection", |
| 700 | 697 | message: "Storage is unavailable.", |
| 701 | 698 | }), |
| ... | ... | @@ -703,7 +700,7 @@ describe("EnvironmentRegistry", () => { |
| 703 | 700 | }); |
| 704 | 701 | |
| 705 | 702 | yield* Effect.gen(function* () { |
| 706 | const registry = yield* EnvironmentRegistry; | |
| 703 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 707 | 704 | yield* registry.start; |
| 708 | 705 | yield* awaitConnectionState( |
| 709 | 706 | registry, |
| ... | ... | @@ -730,7 +727,7 @@ describe("EnvironmentRegistry", () => { |
| 730 | 727 | const harness = yield* makeHarness([]); |
| 731 | 728 | |
| 732 | 729 | yield* Effect.gen(function* () { |
| 733 | const registry = yield* EnvironmentRegistry; | |
| 730 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 734 | 731 | yield* registry.register( |
| 735 | 732 | new BearerConnectionRegistration({ |
| 736 | 733 | target: BEARER_TARGET, |
| ... | ... | @@ -760,7 +757,7 @@ describe("EnvironmentRegistry", () => { |
| 760 | 757 | const harness = yield* makeHarness([]); |
| 761 | 758 | |
| 762 | 759 | yield* Effect.gen(function* () { |
| 763 | const registry = yield* EnvironmentRegistry; | |
| 760 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 764 | 761 | yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); |
| 765 | 762 | yield* awaitConnectionState( |
| 766 | 763 | registry, |
| ... | ... | @@ -791,7 +788,7 @@ describe("EnvironmentRegistry", () => { |
| 791 | 788 | const harness = yield* makeHarness([shadowedTarget]); |
| 792 | 789 | |
| 793 | 790 | yield* Effect.gen(function* () { |
| 794 | const registry = yield* EnvironmentRegistry; | |
| 791 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 795 | 792 | yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); |
| 796 | 793 | |
| 797 | 794 | expect( |
| ... | ... | @@ -825,7 +822,7 @@ describe("EnvironmentRegistry", () => { |
| 825 | 822 | }); |
| 826 | 823 | |
| 827 | 824 | yield* Effect.gen(function* () { |
| 828 | const registry = yield* EnvironmentRegistry; | |
| 825 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 829 | 826 | const persistedRegistration = yield* registry |
| 830 | 827 | .register(new RelayConnectionRegistration({ target: shadowedTarget })) |
| 831 | 828 | .pipe(Effect.forkChild({ startImmediately: true })); |
| ... | ... | @@ -864,7 +861,7 @@ describe("EnvironmentRegistry", () => { |
| 864 | 861 | }); |
| 865 | 862 | |
| 866 | 863 | yield* Effect.gen(function* () { |
| 867 | const registry = yield* EnvironmentRegistry; | |
| 864 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 868 | 865 | yield* registry.start; |
| 869 | 866 | yield* awaitConnectionState( |
| 870 | 867 | registry, |
| ... | ... | @@ -894,7 +891,7 @@ describe("EnvironmentRegistry", () => { |
| 894 | 891 | const harness = yield* makeHarness([]); |
| 895 | 892 | |
| 896 | 893 | yield* Effect.gen(function* () { |
| 897 | const registry = yield* EnvironmentRegistry; | |
| 894 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 898 | 895 | const registration = new PrimaryConnectionRegistration({ target: TARGET }); |
| 899 | 896 | yield* registry.registerPlatform(registration); |
| 900 | 897 | yield* awaitConnectionState( |
| ... | ... | @@ -924,7 +921,7 @@ describe("EnvironmentRegistry", () => { |
| 924 | 921 | ); |
| 925 | 922 | |
| 926 | 923 | yield* Effect.gen(function* () { |
| 927 | const registry = yield* EnvironmentRegistry; | |
| 924 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 928 | 925 | yield* registry.start; |
| 929 | 926 | yield* registry.remove(SSH_CONNECTION.environmentId); |
| 930 | 927 |
packages/client-runtime/src/connection/registry.ts+107-102| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Equal from "effect/Equal"; |
| ... | ... | @@ -12,120 +12,124 @@ import * as Semaphore from "effect/Semaphore"; |
| 12 | 12 | import * as Stream from "effect/Stream"; |
| 13 | 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 14 | 14 | |
| 15 | import { SshEnvironmentGateway } from "../platform/capabilities.ts"; | |
| 15 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 16 | 16 | import { |
| 17 | 17 | type ConnectionCatalogEntry, |
| 18 | 18 | type ConnectionRegistration, |
| 19 | ConnectionProfileStore, | |
| 20 | 19 | type PrimaryConnectionRegistration, |
| 21 | 20 | SshConnectionProfile, |
| 22 | 21 | connectionRegistrationCatalogEntry, |
| 23 | 22 | } from "./catalog.ts"; |
| 24 | import { Connectivity } from "./connectivity.ts"; | |
| 23 | import * as ConnectionProfileStore from "./profileStore.ts"; | |
| 24 | import * as Connectivity from "./connectivity.ts"; | |
| 25 | 25 | import type { |
| 26 | 26 | ConnectionAttemptError, |
| 27 | 27 | ConnectionTarget, |
| 28 | 28 | NetworkStatus, |
| 29 | 29 | SupervisorConnectionState, |
| 30 | 30 | } from "./model.ts"; |
| 31 | import { | |
| 32 | type ConnectionPersistenceError, | |
| 33 | ConnectionRegistrationStore, | |
| 34 | ConnectionTargetStore, | |
| 35 | EnvironmentCacheStore, | |
| 36 | EnvironmentOwnedDataCleanup, | |
| 37 | } from "../platform/persistence.ts"; | |
| 38 | import { | |
| 39 | EnvironmentSupervisor, | |
| 40 | type EnvironmentSupervisorService, | |
| 41 | makeEnvironmentSupervisor, | |
| 42 | } from "./supervisor.ts"; | |
| 43 | import { ConnectionDriver } from "./driver.ts"; | |
| 44 | import { ConnectionWakeups } from "./wakeups.ts"; | |
| 31 | import * as Persistence from "../platform/persistence.ts"; | |
| 32 | import * as EnvironmentSupervisor from "./supervisor.ts"; | |
| 33 | import * as ConnectionDriver from "./driver.ts"; | |
| 34 | import * as ConnectionWakeups from "./wakeups.ts"; | |
| 45 | 35 | |
| 46 | 36 | const isSshConnectionProfile = Schema.is(SshConnectionProfile); |
| 47 | 37 | |
| 48 | 38 | export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass<EnvironmentNotRegisteredError>()( |
| 49 | 39 | "EnvironmentNotRegisteredError", |
| 50 | 40 | { |
| 51 | environmentId: Schema.String, | |
| 52 | message: Schema.String, | |
| 41 | environmentId: EnvironmentId, | |
| 53 | 42 | }, |
| 54 | ) {} | |
| 43 | ) { | |
| 44 | override get message(): string { | |
| 45 | return `Environment ${this.environmentId} is not registered.`; | |
| 46 | } | |
| 47 | } | |
| 55 | 48 | |
| 56 | 49 | export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass<PlatformEnvironmentRemovalError>()( |
| 57 | 50 | "PlatformEnvironmentRemovalError", |
| 58 | 51 | { |
| 59 | environmentId: Schema.String, | |
| 60 | message: Schema.String, | |
| 61 | }, | |
| 62 | ) {} | |
| 63 | ||
| 64 | export interface EnvironmentRegistryService { | |
| 65 | readonly entries: SubscriptionRef.SubscriptionRef< | |
| 66 | ReadonlyMap<EnvironmentId, ConnectionCatalogEntry> | |
| 67 | >; | |
| 68 | readonly networkStatus: SubscriptionRef.SubscriptionRef<NetworkStatus>; | |
| 69 | readonly start: Effect.Effect<void>; | |
| 70 | readonly register: ( | |
| 71 | registration: ConnectionRegistration, | |
| 72 | ) => Effect.Effect<void, ConnectionPersistenceError>; | |
| 73 | readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>; | |
| 74 | readonly remove: ( | |
| 75 | 52 | environmentId: EnvironmentId, |
| 76 | ) => Effect.Effect< | |
| 77 | void, | |
| 78 | | ConnectionPersistenceError | |
| 79 | | ConnectionAttemptError | |
| 80 | | EnvironmentNotRegisteredError | |
| 81 | | PlatformEnvironmentRemovalError | |
| 82 | >; | |
| 83 | readonly removeRelayEnvironments: () => Effect.Effect< | |
| 84 | void, | |
| 85 | ConnectionPersistenceError | ConnectionAttemptError | PlatformEnvironmentRemovalError | |
| 86 | >; | |
| 87 | readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>; | |
| 88 | readonly state: ( | |
| 89 | environmentId: EnvironmentId, | |
| 90 | ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>; | |
| 91 | readonly stateChanges: ( | |
| 92 | environmentId: EnvironmentId, | |
| 93 | ) => Stream.Stream<SupervisorConnectionState, EnvironmentNotRegisteredError>; | |
| 94 | readonly run: <A, E, R>( | |
| 95 | environmentId: EnvironmentId, | |
| 96 | effect: Effect.Effect<A, E, R>, | |
| 97 | ) => Effect.Effect<A, E | EnvironmentNotRegisteredError, Exclude<R, EnvironmentSupervisor>>; | |
| 98 | readonly runStream: <A, E, R>( | |
| 99 | environmentId: EnvironmentId, | |
| 100 | stream: Stream.Stream<A, E, R>, | |
| 101 | ) => Stream.Stream<A, E | EnvironmentNotRegisteredError, Exclude<R, EnvironmentSupervisor>>; | |
| 102 | readonly followStream: <A, E, R>( | |
| 103 | environmentId: EnvironmentId, | |
| 104 | stream: Stream.Stream<A, E, R>, | |
| 105 | ) => Stream.Stream<A, E, Exclude<R, EnvironmentSupervisor>>; | |
| 53 | }, | |
| 54 | ) { | |
| 55 | override get message(): string { | |
| 56 | return `Platform-managed environment ${this.environmentId} cannot be removed.`; | |
| 57 | } | |
| 106 | 58 | } |
| 107 | 59 | |
| 108 | 60 | export class EnvironmentRegistry extends Context.Service< |
| 109 | 61 | EnvironmentRegistry, |
| 110 | EnvironmentRegistryService | |
| 62 | { | |
| 63 | readonly entries: SubscriptionRef.SubscriptionRef< | |
| 64 | ReadonlyMap<EnvironmentId, ConnectionCatalogEntry> | |
| 65 | >; | |
| 66 | readonly networkStatus: SubscriptionRef.SubscriptionRef<NetworkStatus>; | |
| 67 | readonly start: Effect.Effect<void>; | |
| 68 | readonly register: ( | |
| 69 | registration: ConnectionRegistration, | |
| 70 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; | |
| 71 | readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>; | |
| 72 | readonly remove: ( | |
| 73 | environmentId: EnvironmentId, | |
| 74 | ) => Effect.Effect< | |
| 75 | void, | |
| 76 | | Persistence.ConnectionPersistenceError | |
| 77 | | ConnectionAttemptError | |
| 78 | | EnvironmentNotRegisteredError | |
| 79 | | PlatformEnvironmentRemovalError | |
| 80 | >; | |
| 81 | readonly removeRelayEnvironments: () => Effect.Effect< | |
| 82 | void, | |
| 83 | | Persistence.ConnectionPersistenceError | |
| 84 | | ConnectionAttemptError | |
| 85 | | PlatformEnvironmentRemovalError | |
| 86 | >; | |
| 87 | readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>; | |
| 88 | readonly state: ( | |
| 89 | environmentId: EnvironmentId, | |
| 90 | ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>; | |
| 91 | readonly stateChanges: ( | |
| 92 | environmentId: EnvironmentId, | |
| 93 | ) => Stream.Stream<SupervisorConnectionState, EnvironmentNotRegisteredError>; | |
| 94 | readonly run: <A, E, R>( | |
| 95 | environmentId: EnvironmentId, | |
| 96 | effect: Effect.Effect<A, E, R>, | |
| 97 | ) => Effect.Effect< | |
| 98 | A, | |
| 99 | E | EnvironmentNotRegisteredError, | |
| 100 | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor> | |
| 101 | >; | |
| 102 | readonly runStream: <A, E, R>( | |
| 103 | environmentId: EnvironmentId, | |
| 104 | stream: Stream.Stream<A, E, R>, | |
| 105 | ) => Stream.Stream< | |
| 106 | A, | |
| 107 | E | EnvironmentNotRegisteredError, | |
| 108 | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor> | |
| 109 | >; | |
| 110 | readonly followStream: <A, E, R>( | |
| 111 | environmentId: EnvironmentId, | |
| 112 | stream: Stream.Stream<A, E, R>, | |
| 113 | ) => Stream.Stream<A, E, Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>>; | |
| 114 | } | |
| 111 | 115 | >()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {} |
| 112 | 116 | |
| 113 | 117 | interface EnvironmentServiceScope { |
| 114 | 118 | readonly entry: ConnectionCatalogEntry; |
| 115 | readonly supervisor: EnvironmentSupervisorService; | |
| 119 | readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; | |
| 116 | 120 | readonly scope: Scope.Closeable; |
| 117 | 121 | } |
| 118 | 122 | |
| 119 | const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* () { | |
| 120 | const storage = yield* ConnectionTargetStore; | |
| 121 | const registrations = yield* ConnectionRegistrationStore; | |
| 122 | const cache = yield* EnvironmentCacheStore; | |
| 123 | const ownedDataCleanup = yield* EnvironmentOwnedDataCleanup; | |
| 124 | const profiles = yield* ConnectionProfileStore; | |
| 125 | const connectivity = yield* Connectivity; | |
| 126 | const driver = yield* ConnectionDriver; | |
| 127 | const wakeups = yield* ConnectionWakeups; | |
| 128 | const ssh = yield* SshEnvironmentGateway; | |
| 123 | export const make = Effect.gen(function* () { | |
| 124 | const storage = yield* Persistence.ConnectionTargetStore; | |
| 125 | const registrations = yield* Persistence.ConnectionRegistrationStore; | |
| 126 | const cache = yield* Persistence.EnvironmentCacheStore; | |
| 127 | const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup; | |
| 128 | const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; | |
| 129 | const connectivity = yield* Connectivity.Connectivity; | |
| 130 | const driver = yield* ConnectionDriver.ConnectionDriver; | |
| 131 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; | |
| 132 | const ssh = yield* ClientCapabilities.SshEnvironmentGateway; | |
| 129 | 133 | const persistedTargets = yield* storage.list; |
| 130 | 134 | const initialEntries = new Map( |
| 131 | 135 | yield* Effect.forEach( |
| ... | ... | @@ -215,7 +219,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* |
| 215 | 219 | if (entry === undefined) { |
| 216 | 220 | return yield* new EnvironmentNotRegisteredError({ |
| 217 | 221 | environmentId, |
| 218 | message: `Environment ${environmentId} is not registered.`, | |
| 219 | 222 | }); |
| 220 | 223 | } |
| 221 | 224 | return entry; |
| ... | ... | @@ -241,12 +244,12 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* |
| 241 | 244 | Effect.gen(function* () { |
| 242 | 245 | const environmentId = entry.target.environmentId; |
| 243 | 246 | const scope = yield* Scope.make(); |
| 244 | const supervisor = yield* makeEnvironmentSupervisor(entry, { | |
| 247 | const supervisor = yield* EnvironmentSupervisor.make(entry, { | |
| 245 | 248 | initiallyDesired: false, |
| 246 | 249 | }).pipe( |
| 247 | Effect.provideService(Connectivity, connectivity), | |
| 248 | Effect.provideService(ConnectionDriver, driver), | |
| 249 | Effect.provideService(ConnectionWakeups, wakeups), | |
| 250 | Effect.provideService(Connectivity.Connectivity, connectivity), | |
| 251 | Effect.provideService(ConnectionDriver.ConnectionDriver, driver), | |
| 252 | Effect.provideService(ConnectionWakeups.ConnectionWakeups, wakeups), | |
| 250 | 253 | Scope.provide(scope), |
| 251 | 254 | Effect.onError(() => Scope.close(scope, Exit.void)), |
| 252 | 255 | ); |
| ... | ... | @@ -280,28 +283,30 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* |
| 280 | 283 | ); |
| 281 | 284 | }); |
| 282 | 285 | |
| 283 | const run: EnvironmentRegistryService["run"] = Effect.fn("EnvironmentRegistry.run")(function* < | |
| 284 | A, | |
| 285 | E, | |
| 286 | R, | |
| 287 | >(environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) { | |
| 288 | const supervisor = yield* acquireSupervisor(environmentId); | |
| 289 | return yield* Effect.provideService(effect, EnvironmentSupervisor, supervisor); | |
| 290 | }); | |
| 286 | const run: EnvironmentRegistry["Service"]["run"] = Effect.fn("EnvironmentRegistry.run")( | |
| 287 | function* <A, E, R>(environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) { | |
| 288 | const supervisor = yield* acquireSupervisor(environmentId); | |
| 289 | return yield* Effect.provideService( | |
| 290 | effect, | |
| 291 | EnvironmentSupervisor.EnvironmentSupervisor, | |
| 292 | supervisor, | |
| 293 | ); | |
| 294 | }, | |
| 295 | ); | |
| 291 | 296 | |
| 292 | const runStream: EnvironmentRegistryService["runStream"] = <A, E, R>( | |
| 297 | const runStream: EnvironmentRegistry["Service"]["runStream"] = <A, E, R>( | |
| 293 | 298 | environmentId: EnvironmentId, |
| 294 | 299 | stream: Stream.Stream<A, E, R>, |
| 295 | 300 | ) => |
| 296 | 301 | Stream.unwrap( |
| 297 | 302 | acquireSupervisor(environmentId).pipe( |
| 298 | 303 | Effect.map((supervisor) => |
| 299 | Stream.provideService(stream, EnvironmentSupervisor, supervisor), | |
| 304 | Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 300 | 305 | ), |
| 301 | 306 | ), |
| 302 | 307 | ); |
| 303 | 308 | |
| 304 | const followStream: EnvironmentRegistryService["followStream"] = <A, E, R>( | |
| 309 | const followStream: EnvironmentRegistry["Service"]["followStream"] = <A, E, R>( | |
| 305 | 310 | environmentId: EnvironmentId, |
| 306 | 311 | stream: Stream.Stream<A, E, R>, |
| 307 | 312 | ) => |
| ... | ... | @@ -320,7 +325,11 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* |
| 320 | 325 | Effect.match({ |
| 321 | 326 | onFailure: () => Stream.empty, |
| 322 | 327 | onSuccess: (supervisor) => |
| 323 | Stream.provideService(stream, EnvironmentSupervisor, supervisor), | |
| 328 | Stream.provideService( | |
| 329 | stream, | |
| 330 | EnvironmentSupervisor.EnvironmentSupervisor, | |
| 331 | supervisor, | |
| 332 | ), | |
| 324 | 333 | }), |
| 325 | 334 | ), |
| 326 | 335 | ), |
| ... | ... | @@ -443,7 +452,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* |
| 443 | 452 | if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { |
| 444 | 453 | return yield* new PlatformEnvironmentRemovalError({ |
| 445 | 454 | environmentId, |
| 446 | message: "Platform-managed environments cannot be removed.", | |
| 447 | 455 | }); |
| 448 | 456 | } |
| 449 | 457 | const target = (yield* getEntry(environmentId)).target; |
| ... | ... | @@ -532,7 +540,7 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* |
| 532 | 540 | followStream( |
| 533 | 541 | environmentId, |
| 534 | 542 | Stream.unwrap( |
| 535 | EnvironmentSupervisor.pipe( | |
| 543 | EnvironmentSupervisor.EnvironmentSupervisor.pipe( | |
| 536 | 544 | Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), |
| 537 | 545 | ), |
| 538 | 546 | ), |
| ... | ... | @@ -570,7 +578,4 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* |
| 570 | 578 | }); |
| 571 | 579 | }); |
| 572 | 580 | |
| 573 | export const environmentRegistryLayer = Layer.effect( | |
| 574 | EnvironmentRegistry, | |
| 575 | makeEnvironmentRegistry(), | |
| 576 | ); | |
| 581 | export const layer = Layer.effect(EnvironmentRegistry, make); |
packages/client-runtime/src/connection/resolver.test.ts+43-46| ... | ... | @@ -8,30 +8,19 @@ import * as Option from "effect/Option"; |
| 8 | 8 | import * as Ref from "effect/Ref"; |
| 9 | 9 | import * as Tracer from "effect/Tracer"; |
| 10 | 10 | |
| 11 | import { | |
| 12 | ManagedRelayClient, | |
| 13 | ManagedRelayClientError, | |
| 14 | ManagedRelayRequestTimeoutError, | |
| 15 | } from "../relay/managedRelay.ts"; | |
| 16 | import { ConnectionResolver } from "./resolver.ts"; | |
| 17 | import { connectionResolverLayer } from "./resolver.ts"; | |
| 18 | import { | |
| 19 | CloudSession, | |
| 20 | PrimaryEnvironmentAuth, | |
| 21 | RelayDeviceIdentity, | |
| 22 | SshEnvironmentGateway, | |
| 23 | } from "../platform/capabilities.ts"; | |
| 24 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 11 | import * as ManagedRelay from "../relay/managedRelay.ts"; | |
| 12 | import * as ConnectionResolver from "./resolver.ts"; | |
| 13 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 14 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | |
| 25 | 15 | import { |
| 26 | 16 | BearerConnectionCredential, |
| 27 | 17 | BearerConnectionProfile, |
| 28 | 18 | type ConnectionCatalogEntry, |
| 29 | ConnectionCredentialStore, | |
| 30 | ConnectionProfileStore, | |
| 31 | 19 | SshConnectionProfile, |
| 32 | 20 | type ConnectionCredential, |
| 33 | 21 | type ConnectionProfile, |
| 34 | 22 | } from "./catalog.ts"; |
| 23 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | |
| 35 | 24 | import { |
| 36 | 25 | BearerConnectionTarget, |
| 37 | 26 | ConnectionTransientError, |
| ... | ... | @@ -40,6 +29,7 @@ import { |
| 40 | 29 | SshConnectionTarget, |
| 41 | 30 | type ConnectionTarget, |
| 42 | 31 | } from "./model.ts"; |
| 32 | import * as ConnectionProfileStore from "./profileStore.ts"; | |
| 43 | 33 | |
| 44 | 34 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 45 | 35 | const ENDPOINT = { |
| ... | ... | @@ -79,8 +69,10 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer { |
| 79 | 69 | }); |
| 80 | 70 | } |
| 81 | 71 | |
| 82 | function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectEnvironment"]) { | |
| 83 | return ManagedRelayClient.of({ | |
| 72 | function relayClient( | |
| 73 | connectEnvironment: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"], | |
| 74 | ) { | |
| 75 | return ManagedRelay.ManagedRelayClient.of({ | |
| 84 | 76 | relayUrl: "https://relay.example.test", |
| 85 | 77 | listEnvironments: () => unsupported("listEnvironments"), |
| 86 | 78 | listDevices: () => unsupported("listDevices"), |
| ... | ... | @@ -99,29 +91,29 @@ function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectE |
| 99 | 91 | const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { |
| 100 | 92 | readonly profiles?: ReadonlyArray<ConnectionProfile>; |
| 101 | 93 | readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>; |
| 102 | readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"]; | |
| 103 | readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; | |
| 104 | readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; | |
| 94 | readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"]; | |
| 95 | readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; | |
| 96 | readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; | |
| 105 | 97 | readonly primaryBearerToken?: string; |
| 106 | readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"]; | |
| 98 | readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; | |
| 107 | 99 | }) => { |
| 108 | 100 | const profiles = new Map( |
| 109 | 101 | (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), |
| 110 | 102 | ); |
| 111 | 103 | const credentials = new Map(options?.credentials ?? []); |
| 112 | 104 | |
| 113 | const profileStore = ConnectionProfileStore.of({ | |
| 105 | const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({ | |
| 114 | 106 | get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))), |
| 115 | 107 | put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)), |
| 116 | 108 | remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)), |
| 117 | 109 | }); |
| 118 | const credentialStore = ConnectionCredentialStore.of({ | |
| 110 | const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({ | |
| 119 | 111 | get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))), |
| 120 | 112 | put: (connectionId, credential) => |
| 121 | 113 | Effect.sync(() => void credentials.set(connectionId, credential)), |
| 122 | 114 | remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)), |
| 123 | 115 | }); |
| 124 | const remote = RemoteEnvironmentAuthorization.of({ | |
| 116 | const remote = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({ | |
| 125 | 117 | authorizeBearer: |
| 126 | 118 | options?.authorizeBearer ?? |
| 127 | 119 | ((input) => |
| ... | ... | @@ -151,7 +143,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 151 | 143 | }), |
| 152 | 144 | )), |
| 153 | 145 | }); |
| 154 | const ssh = SshEnvironmentGateway.of({ | |
| 146 | const ssh = ClientCapabilities.SshEnvironmentGateway.of({ | |
| 155 | 147 | provision: () => Effect.die("unused"), |
| 156 | 148 | prepare: |
| 157 | 149 | options?.prepareSsh ?? |
| ... | ... | @@ -169,23 +161,28 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 169 | 161 | }); |
| 170 | 162 | |
| 171 | 163 | const dependencies = Layer.mergeAll( |
| 172 | Layer.succeed(ConnectionProfileStore, profileStore), | |
| 173 | Layer.succeed(ConnectionCredentialStore, credentialStore), | |
| 174 | Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })), | |
| 164 | Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore), | |
| 165 | Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), | |
| 175 | 166 | Layer.succeed( |
| 176 | PrimaryEnvironmentAuth, | |
| 177 | PrimaryEnvironmentAuth.of({ | |
| 167 | ClientCapabilities.CloudSession, | |
| 168 | ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }), | |
| 169 | ), | |
| 170 | Layer.succeed( | |
| 171 | ClientCapabilities.PrimaryEnvironmentAuth, | |
| 172 | ClientCapabilities.PrimaryEnvironmentAuth.of({ | |
| 178 | 173 | bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), |
| 179 | 174 | }), |
| 180 | 175 | ), |
| 181 | 176 | Layer.succeed( |
| 182 | RelayDeviceIdentity, | |
| 183 | RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }), | |
| 177 | ClientCapabilities.RelayDeviceIdentity, | |
| 178 | ClientCapabilities.RelayDeviceIdentity.of({ | |
| 179 | deviceId: Effect.succeed(Option.some("device-1")), | |
| 180 | }), | |
| 184 | 181 | ), |
| 185 | Layer.succeed(RemoteEnvironmentAuthorization, remote), | |
| 186 | Layer.succeed(SshEnvironmentGateway, ssh), | |
| 182 | Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), | |
| 183 | Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), | |
| 187 | 184 | Layer.succeed( |
| 188 | ManagedRelayClient, | |
| 185 | ManagedRelay.ManagedRelayClient, | |
| 189 | 186 | relayClient( |
| 190 | 187 | options?.connectEnvironment ?? |
| 191 | 188 | ((input) => |
| ... | ... | @@ -199,14 +196,14 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 199 | 196 | ), |
| 200 | 197 | ); |
| 201 | 198 | |
| 202 | return Effect.succeed(connectionResolverLayer.pipe(Layer.provide(dependencies))); | |
| 199 | return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies))); | |
| 203 | 200 | }); |
| 204 | 201 | |
| 205 | 202 | describe("ConnectionResolver", () => { |
| 206 | 203 | it.effect("prepares a primary environment without remote capabilities", () => |
| 207 | 204 | Effect.gen(function* () { |
| 208 | 205 | const brokerLayer = yield* makeDependencies(); |
| 209 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 206 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 210 | 207 | const target = new PrimaryConnectionTarget({ |
| 211 | 208 | environmentId: ENVIRONMENT_ID, |
| 212 | 209 | label: "Primary", |
| ... | ... | @@ -244,7 +241,7 @@ describe("ConnectionResolver", () => { |
| 244 | 241 | }), |
| 245 | 242 | ), |
| 246 | 243 | }); |
| 247 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 244 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 248 | 245 | const target = new PrimaryConnectionTarget({ |
| 249 | 246 | environmentId: ENVIRONMENT_ID, |
| 250 | 247 | label: "Primary", |
| ... | ... | @@ -292,7 +289,7 @@ describe("ConnectionResolver", () => { |
| 292 | 289 | }), |
| 293 | 290 | ), |
| 294 | 291 | }); |
| 295 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 292 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 296 | 293 | |
| 297 | 294 | expect( |
| 298 | 295 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, |
| ... | ... | @@ -349,7 +346,7 @@ describe("ConnectionResolver", () => { |
| 349 | 346 | }), |
| 350 | 347 | ), |
| 351 | 348 | }); |
| 352 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 349 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 353 | 350 | |
| 354 | 351 | expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); |
| 355 | 352 | expect(yield* Ref.get(relayInputs)).toEqual([ |
| ... | ... | @@ -387,7 +384,7 @@ describe("ConnectionResolver", () => { |
| 387 | 384 | Effect.withSpan("test.remote.authorizeDpop"), |
| 388 | 385 | ), |
| 389 | 386 | }); |
| 390 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 387 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 391 | 388 | |
| 392 | 389 | yield* broker |
| 393 | 390 | .prepare(catalogEntry(target)) |
| ... | ... | @@ -431,7 +428,7 @@ describe("ConnectionResolver", () => { |
| 431 | 428 | }), |
| 432 | 429 | ), |
| 433 | 430 | }); |
| 434 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 431 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 435 | 432 | |
| 436 | 433 | expect( |
| 437 | 434 | (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, |
| ... | ... | @@ -449,15 +446,15 @@ describe("ConnectionResolver", () => { |
| 449 | 446 | const brokerLayer = yield* makeDependencies({ |
| 450 | 447 | connectEnvironment: () => |
| 451 | 448 | Effect.fail( |
| 452 | new ManagedRelayClientError({ | |
| 449 | new ManagedRelay.ManagedRelayClientError({ | |
| 453 | 450 | message: "Relay timed out.", |
| 454 | cause: new ManagedRelayRequestTimeoutError({ | |
| 451 | cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ | |
| 455 | 452 | message: "Relay timed out.", |
| 456 | 453 | }), |
| 457 | 454 | }), |
| 458 | 455 | ), |
| 459 | 456 | }); |
| 460 | const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 457 | const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); | |
| 461 | 458 | const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); |
| 462 | 459 | |
| 463 | 460 | expect(error).toBeInstanceOf(ConnectionTransientError); |
packages/client-runtime/src/connection/resolver.ts+45-51| ... | ... | @@ -6,22 +6,16 @@ import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import * as Schema from "effect/Schema"; |
| 8 | 8 | |
| 9 | import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; | |
| 10 | import { ManagedRelayClient } from "../relay/managedRelay.ts"; | |
| 11 | import { | |
| 12 | CloudSession, | |
| 13 | PrimaryEnvironmentAuth, | |
| 14 | RelayDeviceIdentity, | |
| 15 | SshEnvironmentGateway, | |
| 16 | } from "../platform/capabilities.ts"; | |
| 9 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | |
| 10 | import * as ManagedRelay from "../relay/managedRelay.ts"; | |
| 11 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 17 | 12 | import { |
| 18 | 13 | BearerConnectionCredential, |
| 19 | 14 | BearerConnectionProfile, |
| 20 | 15 | type ConnectionCatalogEntry, |
| 21 | ConnectionCredentialStore, | |
| 22 | ConnectionProfileStore, | |
| 23 | 16 | SshConnectionProfile, |
| 24 | 17 | } from "./catalog.ts"; |
| 18 | import * as ConnectionCredentialStore from "./credentialStore.ts"; | |
| 25 | 19 | import { |
| 26 | 20 | credentialMissingError, |
| 27 | 21 | environmentMismatchError, |
| ... | ... | @@ -37,6 +31,7 @@ import type { |
| 37 | 31 | SshConnectionTarget, |
| 38 | 32 | } from "./model.ts"; |
| 39 | 33 | import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; |
| 34 | import * as ConnectionProfileStore from "./profileStore.ts"; | |
| 40 | 35 | |
| 41 | 36 | export class ConnectionResolver extends Context.Service< |
| 42 | 37 | ConnectionResolver, |
| ... | ... | @@ -60,8 +55,8 @@ function primarySocketUrl(target: PrimaryConnectionTarget): string { |
| 60 | 55 | } |
| 61 | 56 | |
| 62 | 57 | const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { |
| 63 | const auth = yield* PrimaryEnvironmentAuth; | |
| 64 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 58 | const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; | |
| 59 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 65 | 60 | |
| 66 | 61 | return Effect.fn("clientRuntime.connection.broker.primary")(function* ( |
| 67 | 62 | target: PrimaryConnectionTarget, |
| ... | ... | @@ -92,8 +87,8 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary |
| 92 | 87 | }); |
| 93 | 88 | |
| 94 | 89 | const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { |
| 95 | const credentials = yield* ConnectionCredentialStore; | |
| 96 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 90 | const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; | |
| 91 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 97 | 92 | |
| 98 | 93 | return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( |
| 99 | 94 | entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, |
| ... | ... | @@ -106,7 +101,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") |
| 106 | 101 | if (!isBearerProfile(profile)) { |
| 107 | 102 | return yield* new ConnectionBlockedError({ |
| 108 | 103 | reason: "configuration", |
| 109 | message: `Connection profile ${target.connectionId} is not a bearer connection.`, | |
| 104 | detail: `Connection profile ${target.connectionId} is not a bearer connection.`, | |
| 110 | 105 | }); |
| 111 | 106 | } |
| 112 | 107 | if (profile.environmentId !== target.environmentId) { |
| ... | ... | @@ -144,10 +139,10 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") |
| 144 | 139 | }); |
| 145 | 140 | |
| 146 | 141 | const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { |
| 147 | const relay = yield* ManagedRelayClient; | |
| 148 | const session = yield* CloudSession; | |
| 149 | const identity = yield* RelayDeviceIdentity; | |
| 150 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 142 | const relay = yield* ManagedRelay.ManagedRelayClient; | |
| 143 | const session = yield* ClientCapabilities.CloudSession; | |
| 144 | const identity = yield* ClientCapabilities.RelayDeviceIdentity; | |
| 145 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 151 | 146 | |
| 152 | 147 | return Effect.fnUntraced( |
| 153 | 148 | function* (target: RelayConnectionTarget) { |
| ... | ... | @@ -192,9 +187,9 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f |
| 192 | 187 | }); |
| 193 | 188 | |
| 194 | 189 | const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { |
| 195 | const profiles = yield* ConnectionProfileStore; | |
| 196 | const ssh = yield* SshEnvironmentGateway; | |
| 197 | const remote = yield* RemoteEnvironmentAuthorization; | |
| 190 | const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; | |
| 191 | const ssh = yield* ClientCapabilities.SshEnvironmentGateway; | |
| 192 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | |
| 198 | 193 | |
| 199 | 194 | return Effect.fn("clientRuntime.connection.broker.ssh")(function* ( |
| 200 | 195 | entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, |
| ... | ... | @@ -207,7 +202,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct |
| 207 | 202 | if (!isSshProfile(profile)) { |
| 208 | 203 | return yield* new ConnectionBlockedError({ |
| 209 | 204 | reason: "configuration", |
| 210 | message: `Connection profile ${target.connectionId} is not an SSH connection.`, | |
| 205 | detail: `Connection profile ${target.connectionId} is not an SSH connection.`, | |
| 211 | 206 | }); |
| 212 | 207 | } |
| 213 | 208 | if (profile.environmentId !== target.environmentId) { |
| ... | ... | @@ -246,34 +241,33 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct |
| 246 | 241 | }); |
| 247 | 242 | }); |
| 248 | 243 | |
| 249 | export const connectionResolverLayer = Layer.effect( | |
| 250 | ConnectionResolver, | |
| 251 | Effect.gen(function* () { | |
| 252 | const primary = yield* makePrimaryBroker(); | |
| 253 | const bearer = yield* makeBearerBroker(); | |
| 254 | const relay = yield* makeRelayBroker(); | |
| 255 | const ssh = yield* makeSshBroker(); | |
| 244 | export const make = Effect.gen(function* () { | |
| 245 | const primary = yield* makePrimaryBroker(); | |
| 246 | const bearer = yield* makeBearerBroker(); | |
| 247 | const relay = yield* makeRelayBroker(); | |
| 248 | const ssh = yield* makeSshBroker(); | |
| 256 | 249 | |
| 257 | const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( | |
| 258 | entry: ConnectionCatalogEntry, | |
| 259 | ) { | |
| 260 | const target: ConnectionTarget = entry.target; | |
| 261 | yield* Effect.annotateCurrentSpan({ | |
| 262 | "connection.environment.id": target.environmentId, | |
| 263 | "connection.target.kind": target._tag, | |
| 264 | }); | |
| 265 | switch (target._tag) { | |
| 266 | case "PrimaryConnectionTarget": | |
| 267 | return yield* primary(target); | |
| 268 | case "BearerConnectionTarget": | |
| 269 | return yield* bearer({ ...entry, target }); | |
| 270 | case "RelayConnectionTarget": | |
| 271 | return yield* relay(target); | |
| 272 | case "SshConnectionTarget": | |
| 273 | return yield* ssh({ ...entry, target }); | |
| 274 | } | |
| 250 | const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( | |
| 251 | entry: ConnectionCatalogEntry, | |
| 252 | ) { | |
| 253 | const target: ConnectionTarget = entry.target; | |
| 254 | yield* Effect.annotateCurrentSpan({ | |
| 255 | "connection.environment.id": target.environmentId, | |
| 256 | "connection.target.kind": target._tag, | |
| 275 | 257 | }); |
| 258 | switch (target._tag) { | |
| 259 | case "PrimaryConnectionTarget": | |
| 260 | return yield* primary(target); | |
| 261 | case "BearerConnectionTarget": | |
| 262 | return yield* bearer({ ...entry, target }); | |
| 263 | case "RelayConnectionTarget": | |
| 264 | return yield* relay(target); | |
| 265 | case "SshConnectionTarget": | |
| 266 | return yield* ssh({ ...entry, target }); | |
| 267 | } | |
| 268 | }); | |
| 269 | ||
| 270 | return ConnectionResolver.of({ prepare }); | |
| 271 | }); | |
| 276 | 272 | |
| 277 | return ConnectionResolver.of({ prepare }); | |
| 278 | }), | |
| 279 | ); | |
| 273 | export const layer = Layer.effect(ConnectionResolver, make); |
packages/client-runtime/src/connection/supervisor.test.ts+43-44| ... | ... | @@ -13,12 +13,8 @@ import * as Tracer from "effect/Tracer"; |
| 13 | 13 | |
| 14 | 14 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 15 | 15 | import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 16 | import { Connectivity } from "./connectivity.ts"; | |
| 17 | import { | |
| 18 | ConnectionDriver, | |
| 19 | type ConnectionDriverProgress, | |
| 20 | type EnvironmentConnectionLease, | |
| 21 | } from "./driver.ts"; | |
| 16 | import * as Connectivity from "./connectivity.ts"; | |
| 17 | import * as ConnectionDriver from "./driver.ts"; | |
| 22 | 18 | import { |
| 23 | 19 | ConnectionBlockedError, |
| 24 | 20 | ConnectionTransientError, |
| ... | ... | @@ -30,9 +26,9 @@ import { |
| 30 | 26 | type PreparedConnection, |
| 31 | 27 | type SupervisorConnectionState, |
| 32 | 28 | } from "./model.ts"; |
| 33 | import type { RpcSession } from "../rpc/session.ts"; | |
| 34 | import { makeEnvironmentSupervisor } from "./supervisor.ts"; | |
| 35 | import { ConnectionWakeups } from "./wakeups.ts"; | |
| 29 | import * as RpcSession from "../rpc/session.ts"; | |
| 30 | import * as EnvironmentSupervisor from "./supervisor.ts"; | |
| 31 | import * as ConnectionWakeups from "./wakeups.ts"; | |
| 36 | 32 | |
| 37 | 33 | const TARGET = new PrimaryConnectionTarget({ |
| 38 | 34 | environmentId: EnvironmentId.make("environment-1"), |
| ... | ... | @@ -70,14 +66,14 @@ const TEST_RPC_CLIENT = {} as WsRpcProtocolClient; |
| 70 | 66 | function transient(message = "Connection failed.") { |
| 71 | 67 | return new ConnectionTransientError({ |
| 72 | 68 | reason: "transport", |
| 73 | message, | |
| 69 | detail: message, | |
| 74 | 70 | }); |
| 75 | 71 | } |
| 76 | 72 | |
| 77 | 73 | function blocked(message = "Authentication required.") { |
| 78 | 74 | return new ConnectionBlockedError({ |
| 79 | 75 | reason: "authentication", |
| 80 | message, | |
| 76 | detail: message, | |
| 81 | 77 | }); |
| 82 | 78 | } |
| 83 | 79 | |
| ... | ... | @@ -137,7 +133,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: |
| 137 | 133 | ReadonlyArray<Deferred.Deferred<never, ConnectionTransientError>> |
| 138 | 134 | >([]); |
| 139 | 135 | |
| 140 | const connectivity = Connectivity.of({ | |
| 136 | const connectivity = Connectivity.Connectivity.of({ | |
| 141 | 137 | status: SubscriptionRef.get(networkStatus), |
| 142 | 138 | changes: SubscriptionRef.changes(networkStatus), |
| 143 | 139 | }); |
| ... | ... | @@ -152,7 +148,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: |
| 152 | 148 | |
| 153 | 149 | const connect = Effect.fn("TestConnectionDriver.connect")(function* ( |
| 154 | 150 | entry: ConnectionCatalogEntry, |
| 155 | reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>, | |
| 151 | reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect<void>, | |
| 156 | 152 | ) { |
| 157 | 153 | const target = entry.target; |
| 158 | 154 | yield* reportProgress({ stage: "preparing" }); |
| ... | ... | @@ -170,27 +166,30 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: |
| 170 | 166 | ready: options?.ready?.(attempt) ?? Effect.void, |
| 171 | 167 | probe: options?.probe?.(attempt) ?? Effect.void, |
| 172 | 168 | closed: Deferred.await(closed), |
| 173 | } satisfies RpcSession), | |
| 169 | } satisfies RpcSession.RpcSession), | |
| 174 | 170 | () => Ref.update(releaseCount, (count) => count + 1), |
| 175 | 171 | ); |
| 176 | 172 | |
| 177 | 173 | yield* reportProgress({ stage: "synchronizing", prepared }); |
| 178 | 174 | yield* session.ready; |
| 179 | return { prepared, session } satisfies EnvironmentConnectionLease; | |
| 175 | return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease; | |
| 180 | 176 | }); |
| 181 | 177 | |
| 182 | 178 | const dependencies = Layer.mergeAll( |
| 183 | Layer.succeed(Connectivity, connectivity), | |
| 179 | Layer.succeed(Connectivity.Connectivity, connectivity), | |
| 184 | 180 | Layer.succeed( |
| 185 | ConnectionWakeups, | |
| 186 | ConnectionWakeups.of({ | |
| 181 | ConnectionWakeups.ConnectionWakeups, | |
| 182 | ConnectionWakeups.ConnectionWakeups.of({ | |
| 187 | 183 | changes: SubscriptionRef.changes(wakeups).pipe( |
| 188 | 184 | Stream.drop(1), |
| 189 | 185 | Stream.map((event) => event.reason), |
| 190 | 186 | ), |
| 191 | 187 | }), |
| 192 | 188 | ), |
| 193 | Layer.succeed(ConnectionDriver, ConnectionDriver.of({ connect })), | |
| 189 | Layer.succeed( | |
| 190 | ConnectionDriver.ConnectionDriver, | |
| 191 | ConnectionDriver.ConnectionDriver.of({ connect }), | |
| 192 | ), | |
| 194 | 193 | ); |
| 195 | 194 | |
| 196 | 195 | return { |
| ... | ... | @@ -235,7 +234,7 @@ describe("EnvironmentSupervisor", () => { |
| 235 | 234 | prepare: (attempt) => |
| 236 | 235 | attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), |
| 237 | 236 | }); |
| 238 | const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { | |
| 237 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { | |
| 239 | 238 | initiallyDesired: true, |
| 240 | 239 | }).pipe( |
| 241 | 240 | Effect.provide(harness.dependencies), |
| ... | ... | @@ -263,7 +262,7 @@ describe("EnvironmentSupervisor", () => { |
| 263 | 262 | it.effect("does not attempt a connection until it is desired", () => |
| 264 | 263 | Effect.gen(function* () { |
| 265 | 264 | const harness = yield* makeHarness(); |
| 266 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( | |
| 265 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( | |
| 267 | 266 | Effect.provide(harness.dependencies), |
| 268 | 267 | ); |
| 269 | 268 | |
| ... | ... | @@ -275,7 +274,7 @@ describe("EnvironmentSupervisor", () => { |
| 275 | 274 | it.effect("does not let the initial connect signal cancel the first attempt", () => |
| 276 | 275 | Effect.gen(function* () { |
| 277 | 276 | const harness = yield* makeHarness(); |
| 278 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( | |
| 277 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe( | |
| 279 | 278 | Effect.provide(harness.dependencies), |
| 280 | 279 | ); |
| 281 | 280 | |
| ... | ... | @@ -290,7 +289,7 @@ describe("EnvironmentSupervisor", () => { |
| 290 | 289 | it.effect("waits while offline and connects immediately when the network returns", () => |
| 291 | 290 | Effect.gen(function* () { |
| 292 | 291 | const harness = yield* makeHarness({ networkStatus: "offline" }); |
| 293 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 292 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 294 | 293 | initiallyDesired: true, |
| 295 | 294 | }).pipe(Effect.provide(harness.dependencies)); |
| 296 | 295 | |
| ... | ... | @@ -317,7 +316,7 @@ describe("EnvironmentSupervisor", () => { |
| 317 | 316 | const harness = yield* makeHarness({ |
| 318 | 317 | prepare: () => Effect.fail(transient()), |
| 319 | 318 | }); |
| 320 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 319 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 321 | 320 | initiallyDesired: true, |
| 322 | 321 | }).pipe(Effect.provide(harness.dependencies)); |
| 323 | 322 | |
| ... | ... | @@ -345,7 +344,7 @@ describe("EnvironmentSupervisor", () => { |
| 345 | 344 | prepare: (attempt) => |
| 346 | 345 | attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never, |
| 347 | 346 | }); |
| 348 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 347 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 349 | 348 | initiallyDesired: true, |
| 350 | 349 | }).pipe(Effect.provide(harness.dependencies)); |
| 351 | 350 | |
| ... | ... | @@ -378,7 +377,7 @@ describe("EnvironmentSupervisor", () => { |
| 378 | 377 | const harness = yield* makeHarness({ |
| 379 | 378 | ready: () => Effect.never, |
| 380 | 379 | }); |
| 381 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 380 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 382 | 381 | initiallyDesired: true, |
| 383 | 382 | }).pipe(Effect.provide(harness.dependencies)); |
| 384 | 383 | |
| ... | ... | @@ -410,7 +409,7 @@ describe("EnvironmentSupervisor", () => { |
| 410 | 409 | const harness = yield* makeHarness({ |
| 411 | 410 | prepare: () => Effect.never, |
| 412 | 411 | }); |
| 413 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 412 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 414 | 413 | initiallyDesired: true, |
| 415 | 414 | }).pipe(Effect.provide(harness.dependencies)); |
| 416 | 415 | |
| ... | ... | @@ -442,7 +441,7 @@ describe("EnvironmentSupervisor", () => { |
| 442 | 441 | ? Effect.die(new Error("Native transport defect.")) |
| 443 | 442 | : Effect.succeed(PREPARED_CONNECTION), |
| 444 | 443 | }); |
| 445 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 444 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 446 | 445 | initiallyDesired: true, |
| 447 | 446 | }).pipe(Effect.provide(harness.dependencies)); |
| 448 | 447 | |
| ... | ... | @@ -470,7 +469,7 @@ describe("EnvironmentSupervisor", () => { |
| 470 | 469 | prepare: (attempt) => |
| 471 | 470 | attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), |
| 472 | 471 | }); |
| 473 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 472 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 474 | 473 | initiallyDesired: true, |
| 475 | 474 | }).pipe(Effect.provide(harness.dependencies)); |
| 476 | 475 | |
| ... | ... | @@ -488,7 +487,7 @@ describe("EnvironmentSupervisor", () => { |
| 488 | 487 | prepare: (attempt) => |
| 489 | 488 | attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), |
| 490 | 489 | }); |
| 491 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 490 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 492 | 491 | initiallyDesired: true, |
| 493 | 492 | }).pipe(Effect.provide(harness.dependencies)); |
| 494 | 493 | |
| ... | ... | @@ -505,7 +504,7 @@ describe("EnvironmentSupervisor", () => { |
| 505 | 504 | it.effect("releases a live session while offline and starts a new generation when online", () => |
| 506 | 505 | Effect.gen(function* () { |
| 507 | 506 | const harness = yield* makeHarness(); |
| 508 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 507 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 509 | 508 | initiallyDesired: true, |
| 510 | 509 | }).pipe(Effect.provide(harness.dependencies)); |
| 511 | 510 | |
| ... | ... | @@ -534,7 +533,7 @@ describe("EnvironmentSupervisor", () => { |
| 534 | 533 | prepare: (attempt) => |
| 535 | 534 | attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), |
| 536 | 535 | }); |
| 537 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 536 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 538 | 537 | initiallyDesired: true, |
| 539 | 538 | }).pipe(Effect.provide(harness.dependencies)); |
| 540 | 539 | |
| ... | ... | @@ -553,7 +552,7 @@ describe("EnvironmentSupervisor", () => { |
| 553 | 552 | prepare: () => |
| 554 | 553 | Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)), |
| 555 | 554 | }); |
| 556 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 555 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 557 | 556 | initiallyDesired: true, |
| 558 | 557 | }).pipe(Effect.provide(harness.dependencies)); |
| 559 | 558 | |
| ... | ... | @@ -591,7 +590,7 @@ describe("EnvironmentSupervisor", () => { |
| 591 | 590 | it.effect("treats an involuntary session close as transient and reconnects", () => |
| 592 | 591 | Effect.gen(function* () { |
| 593 | 592 | const harness = yield* makeHarness(); |
| 594 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 593 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 595 | 594 | initiallyDesired: true, |
| 596 | 595 | }).pipe(Effect.provide(harness.dependencies)); |
| 597 | 596 | |
| ... | ... | @@ -617,7 +616,7 @@ describe("EnvironmentSupervisor", () => { |
| 617 | 616 | it.effect("keeps escalating backoff when a newly opened session flaps", () => |
| 618 | 617 | Effect.gen(function* () { |
| 619 | 618 | const harness = yield* makeHarness(); |
| 620 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 619 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 621 | 620 | initiallyDesired: true, |
| 622 | 621 | }).pipe(Effect.provide(harness.dependencies)); |
| 623 | 622 | |
| ... | ... | @@ -663,7 +662,7 @@ describe("EnvironmentSupervisor", () => { |
| 663 | 662 | Effect.andThen(Deferred.succeed(probeCalled, undefined)), |
| 664 | 663 | ), |
| 665 | 664 | }); |
| 666 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 665 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 667 | 666 | initiallyDesired: true, |
| 668 | 667 | }).pipe(Effect.provide(harness.dependencies)); |
| 669 | 668 | |
| ... | ... | @@ -684,7 +683,7 @@ describe("EnvironmentSupervisor", () => { |
| 684 | 683 | probe: (attempt) => |
| 685 | 684 | attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, |
| 686 | 685 | }); |
| 687 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 686 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 688 | 687 | initiallyDesired: true, |
| 689 | 688 | }).pipe(Effect.provide(harness.dependencies)); |
| 690 | 689 | |
| ... | ... | @@ -707,7 +706,7 @@ describe("EnvironmentSupervisor", () => { |
| 707 | 706 | const harness = yield* makeHarness({ |
| 708 | 707 | probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), |
| 709 | 708 | }); |
| 710 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 709 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 711 | 710 | initiallyDesired: true, |
| 712 | 711 | }).pipe(Effect.provide(harness.dependencies)); |
| 713 | 712 | |
| ... | ... | @@ -732,7 +731,7 @@ describe("EnvironmentSupervisor", () => { |
| 732 | 731 | const harness = yield* makeHarness({ |
| 733 | 732 | probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), |
| 734 | 733 | }); |
| 735 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 734 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 736 | 735 | initiallyDesired: true, |
| 737 | 736 | }).pipe(Effect.provide(harness.dependencies)); |
| 738 | 737 | |
| ... | ... | @@ -749,7 +748,7 @@ describe("EnvironmentSupervisor", () => { |
| 749 | 748 | it.effect("does not churn a healthy session when credentials change", () => |
| 750 | 749 | Effect.gen(function* () { |
| 751 | 750 | const harness = yield* makeHarness(); |
| 752 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 751 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 753 | 752 | initiallyDesired: true, |
| 754 | 753 | }).pipe(Effect.provide(harness.dependencies)); |
| 755 | 754 | |
| ... | ... | @@ -766,7 +765,7 @@ describe("EnvironmentSupervisor", () => { |
| 766 | 765 | it.effect("releases and reconnects a relay session when credentials change", () => |
| 767 | 766 | Effect.gen(function* () { |
| 768 | 767 | const harness = yield* makeHarness(); |
| 769 | const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { | |
| 768 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { | |
| 770 | 769 | initiallyDesired: true, |
| 771 | 770 | }).pipe(Effect.provide(harness.dependencies)); |
| 772 | 771 | |
| ... | ... | @@ -791,7 +790,7 @@ describe("EnvironmentSupervisor", () => { |
| 791 | 790 | ? Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)) |
| 792 | 791 | : Effect.succeed(PREPARED_CONNECTION), |
| 793 | 792 | }); |
| 794 | const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { | |
| 793 | const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { | |
| 795 | 794 | initiallyDesired: true, |
| 796 | 795 | }).pipe(Effect.provide(harness.dependencies)); |
| 797 | 796 | |
| ... | ... | @@ -807,7 +806,7 @@ describe("EnvironmentSupervisor", () => { |
| 807 | 806 | it.effect("explicit disconnect releases the session and returns to available", () => |
| 808 | 807 | Effect.gen(function* () { |
| 809 | 808 | const harness = yield* makeHarness(); |
| 810 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 809 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 811 | 810 | initiallyDesired: true, |
| 812 | 811 | }).pipe(Effect.provide(harness.dependencies)); |
| 813 | 812 | |
| ... | ... | @@ -824,7 +823,7 @@ describe("EnvironmentSupervisor", () => { |
| 824 | 823 | it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () => |
| 825 | 824 | Effect.gen(function* () { |
| 826 | 825 | const harness = yield* makeHarness(); |
| 827 | const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { | |
| 826 | const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { | |
| 828 | 827 | initiallyDesired: true, |
| 829 | 828 | }).pipe(Effect.provide(harness.dependencies)); |
| 830 | 829 |
packages/client-runtime/src/connection/supervisor.ts+48-45| ... | ... | @@ -15,12 +15,8 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 15 | 15 | import * as Tracer from "effect/Tracer"; |
| 16 | 16 | |
| 17 | 17 | import type { ConnectionCatalogEntry } from "./catalog.ts"; |
| 18 | import { Connectivity } from "./connectivity.ts"; | |
| 19 | import { | |
| 20 | ConnectionDriver, | |
| 21 | type ConnectionDriverProgress, | |
| 22 | type EnvironmentConnectionLease, | |
| 23 | } from "./driver.ts"; | |
| 18 | import * as Connectivity from "./connectivity.ts"; | |
| 19 | import * as ConnectionDriver from "./driver.ts"; | |
| 24 | 20 | import { |
| 25 | 21 | type ConnectionAttemptError, |
| 26 | 22 | type ConnectionTarget, |
| ... | ... | @@ -29,8 +25,8 @@ import { |
| 29 | 25 | type PreparedConnection, |
| 30 | 26 | type SupervisorConnectionState, |
| 31 | 27 | } from "./model.ts"; |
| 32 | import type { RpcSession } from "../rpc/session.ts"; | |
| 33 | import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts"; | |
| 28 | import * as RpcSession from "../rpc/session.ts"; | |
| 29 | import * as ConnectionWakeups from "./wakeups.ts"; | |
| 34 | 30 | |
| 35 | 31 | const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; |
| 36 | 32 | const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; |
| ... | ... | @@ -47,7 +43,7 @@ type SupervisorSignal = |
| 47 | 43 | | { readonly _tag: "DisconnectRequested" } |
| 48 | 44 | | { readonly _tag: "RetryRequested" } |
| 49 | 45 | | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } |
| 50 | | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeup }; | |
| 46 | | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup }; | |
| 51 | 47 | |
| 52 | 48 | interface PendingRetryTrace { |
| 53 | 49 | readonly previousAttempt: Tracer.Span; |
| ... | ... | @@ -80,7 +76,7 @@ type EstablishmentEvent = |
| 80 | 76 | readonly exit: Exit.Exit< |
| 81 | 77 | { |
| 82 | 78 | readonly attemptSpan: Option.Option<Tracer.Span>; |
| 83 | readonly lease: EnvironmentConnectionLease; | |
| 79 | readonly lease: ConnectionDriver.EnvironmentConnectionLease; | |
| 84 | 80 | }, |
| 85 | 81 | TracedAttemptFailure |
| 86 | 82 | >; |
| ... | ... | @@ -102,16 +98,6 @@ export interface EnvironmentSupervisorOptions { |
| 102 | 98 | readonly initiallyDesired?: boolean; |
| 103 | 99 | } |
| 104 | 100 | |
| 105 | export interface EnvironmentSupervisorService { | |
| 106 | readonly target: ConnectionTarget; | |
| 107 | readonly state: SubscriptionRef.SubscriptionRef<SupervisorConnectionState>; | |
| 108 | readonly session: SubscriptionRef.SubscriptionRef<Option.Option<RpcSession>>; | |
| 109 | readonly prepared: SubscriptionRef.SubscriptionRef<Option.Option<PreparedConnection>>; | |
| 110 | readonly connect: Effect.Effect<void>; | |
| 111 | readonly disconnect: Effect.Effect<void>; | |
| 112 | readonly retryNow: Effect.Effect<void>; | |
| 113 | } | |
| 114 | ||
| 115 | 101 | function retryDelayMs(failureCount: number): number { |
| 116 | 102 | return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; |
| 117 | 103 | } |
| ... | ... | @@ -199,7 +185,7 @@ function failureFromExit<A>( |
| 199 | 185 | failure: { |
| 200 | 186 | error: new ConnectionTransientError({ |
| 201 | 187 | reason: "transport", |
| 202 | message: `${target.label} connection failed unexpectedly.`, | |
| 188 | detail: `${target.label} connection failed unexpectedly.`, | |
| 203 | 189 | }), |
| 204 | 190 | attemptSpan: Option.none(), |
| 205 | 191 | }, |
| ... | ... | @@ -208,34 +194,34 @@ function failureFromExit<A>( |
| 208 | 194 | |
| 209 | 195 | export class EnvironmentSupervisor extends Context.Service< |
| 210 | 196 | EnvironmentSupervisor, |
| 211 | EnvironmentSupervisorService | |
| 212 | >()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") { | |
| 213 | static layer( | |
| 214 | entry: ConnectionCatalogEntry, | |
| 215 | options?: EnvironmentSupervisorOptions, | |
| 216 | ): Layer.Layer< | |
| 217 | EnvironmentSupervisor, | |
| 218 | never, | |
| 219 | Connectivity | ConnectionDriver | ConnectionWakeups | |
| 220 | > { | |
| 221 | return Layer.effect(EnvironmentSupervisor, makeEnvironmentSupervisor(entry, options)); | |
| 197 | { | |
| 198 | readonly target: ConnectionTarget; | |
| 199 | readonly state: SubscriptionRef.SubscriptionRef<SupervisorConnectionState>; | |
| 200 | readonly session: SubscriptionRef.SubscriptionRef<Option.Option<RpcSession.RpcSession>>; | |
| 201 | readonly prepared: SubscriptionRef.SubscriptionRef<Option.Option<PreparedConnection>>; | |
| 202 | readonly connect: Effect.Effect<void>; | |
| 203 | readonly disconnect: Effect.Effect<void>; | |
| 204 | readonly retryNow: Effect.Effect<void>; | |
| 222 | 205 | } |
| 223 | } | |
| 206 | >()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {} | |
| 224 | 207 | |
| 225 | export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")(function* ( | |
| 208 | export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( | |
| 226 | 209 | entry: ConnectionCatalogEntry, |
| 227 | 210 | options?: EnvironmentSupervisorOptions, |
| 228 | 211 | ): Effect.fn.Return< |
| 229 | EnvironmentSupervisorService, | |
| 212 | EnvironmentSupervisor["Service"], | |
| 230 | 213 | never, |
| 231 | Connectivity | ConnectionDriver | Scope.Scope | ConnectionWakeups | |
| 214 | | Connectivity.Connectivity | |
| 215 | | ConnectionDriver.ConnectionDriver | |
| 216 | | Scope.Scope | |
| 217 | | ConnectionWakeups.ConnectionWakeups | |
| 232 | 218 | > { |
| 233 | 219 | const target = entry.target; |
| 234 | 220 | yield* annotateTarget(target); |
| 235 | 221 | |
| 236 | const connectivity = yield* Connectivity; | |
| 237 | const driver = yield* ConnectionDriver; | |
| 238 | const wakeups = yield* ConnectionWakeups; | |
| 222 | const connectivity = yield* Connectivity.Connectivity; | |
| 223 | const driver = yield* ConnectionDriver.ConnectionDriver; | |
| 224 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; | |
| 239 | 225 | const initialIntent: SupervisorIntent = { |
| 240 | 226 | desired: options?.initiallyDesired ?? false, |
| 241 | 227 | network: yield* connectivity.status, |
| ... | ... | @@ -249,7 +235,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") |
| 249 | 235 | ? offlineState(initialIntent, 0, 0, null) |
| 250 | 236 | : connectingState(initialIntent, 0, 1, null), |
| 251 | 237 | ); |
| 252 | const session = yield* SubscriptionRef.make<Option.Option<RpcSession>>(Option.none()); | |
| 238 | const session = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(Option.none()); | |
| 253 | 239 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none()); |
| 254 | 240 | |
| 255 | 241 | const clearLease = Effect.all( |
| ... | ... | @@ -280,7 +266,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") |
| 280 | 266 | attempt: number, |
| 281 | 267 | generation: number, |
| 282 | 268 | lastFailure: ConnectionAttemptError | null, |
| 283 | progress: ConnectionDriverProgress, | |
| 269 | progress: ConnectionDriver.ConnectionDriverProgress, | |
| 284 | 270 | ) { |
| 285 | 271 | if ("prepared" in progress) { |
| 286 | 272 | yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); |
| ... | ... | @@ -301,7 +287,11 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") |
| 301 | 287 | }); |
| 302 | 288 | |
| 303 | 289 | const traceRelayEstablishment = ( |
| 304 | effect: Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>, | |
| 290 | effect: Effect.Effect< | |
| 291 | ConnectionDriver.EnvironmentConnectionLease, | |
| 292 | ConnectionAttemptError, | |
| 293 | Scope.Scope | |
| 294 | >, | |
| 305 | 295 | attempt: number, |
| 306 | 296 | generation: number, |
| 307 | 297 | pendingRetry: Option.Option<PendingRetryTrace>, |
| ... | ... | @@ -392,7 +382,9 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") |
| 392 | 382 | } |
| 393 | 383 | }); |
| 394 | 384 | |
| 395 | const monitorConnectedLease = Effect.fnUntraced(function* (lease: EnvironmentConnectionLease) { | |
| 385 | const monitorConnectedLease = Effect.fnUntraced(function* ( | |
| 386 | lease: ConnectionDriver.EnvironmentConnectionLease, | |
| 387 | ) { | |
| 396 | 388 | for (;;) { |
| 397 | 389 | const next = yield* Queue.take(signals); |
| 398 | 390 | switch (next._tag) { |
| ... | ... | @@ -417,7 +409,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") |
| 417 | 409 | Effect.fail( |
| 418 | 410 | new ConnectionTransientError({ |
| 419 | 411 | reason: "timeout", |
| 420 | message: `${target.label} did not respond to a connection health check.`, | |
| 412 | detail: `${target.label} did not respond to a connection health check.`, | |
| 421 | 413 | }), |
| 422 | 414 | ), |
| 423 | 415 | }), |
| ... | ... | @@ -499,7 +491,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") |
| 499 | 491 | failure: { |
| 500 | 492 | error: new ConnectionTransientError({ |
| 501 | 493 | reason: "timeout", |
| 502 | message: `${target.label} did not respond during connection setup.`, | |
| 494 | detail: `${target.label} did not respond during connection setup.`, | |
| 503 | 495 | }), |
| 504 | 496 | attemptSpan: Option.none(), |
| 505 | 497 | }, |
| ... | ... | @@ -722,3 +714,14 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make") |
| 722 | 714 | retryNow, |
| 723 | 715 | }); |
| 724 | 716 | }); |
| 717 | ||
| 718 | export const layer = ( | |
| 719 | entry: ConnectionCatalogEntry, | |
| 720 | options?: EnvironmentSupervisorOptions, | |
| 721 | ): Layer.Layer< | |
| 722 | EnvironmentSupervisor, | |
| 723 | never, | |
| 724 | | Connectivity.Connectivity | |
| 725 | | ConnectionDriver.ConnectionDriver | |
| 726 | | ConnectionWakeups.ConnectionWakeups | |
| 727 | > => Layer.effect(EnvironmentSupervisor, make(entry, options)); |
packages/client-runtime/src/connection/wakeups.ts+6| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | import * as Layer from "effect/Layer"; | |
| 2 | 3 | import type * as Stream from "effect/Stream"; |
| 3 | 4 | |
| 4 | 5 | export type ConnectionWakeup = "application-active" | "credentials-changed"; |
| ... | ... | @@ -9,3 +10,8 @@ export class ConnectionWakeups extends Context.Service< |
| 9 | 10 | readonly changes: Stream.Stream<ConnectionWakeup>; |
| 10 | 11 | } |
| 11 | 12 | >()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} |
| 13 | ||
| 14 | export const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); | |
| 15 | ||
| 16 | export const layer = (service: ConnectionWakeups["Service"]) => | |
| 17 | Layer.succeed(ConnectionWakeups, make(service)); |
packages/client-runtime/src/operations/commands.test.ts+8-11| ... | ... | @@ -18,11 +18,8 @@ import { |
| 18 | 18 | PrimaryConnectionTarget, |
| 19 | 19 | type PreparedConnection, |
| 20 | 20 | } from "../connection/model.ts"; |
| 21 | import { | |
| 22 | EnvironmentSupervisor, | |
| 23 | type EnvironmentSupervisorService, | |
| 24 | } from "../connection/supervisor.ts"; | |
| 25 | import type { RpcSession } from "../rpc/session.ts"; | |
| 21 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 22 | import * as RpcSession from "../rpc/session.ts"; | |
| 26 | 23 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 27 | 24 | import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; |
| 28 | 25 | |
| ... | ... | @@ -51,14 +48,14 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct |
| 51 | 48 | return { sequence: dispatched.length }; |
| 52 | 49 | }), |
| 53 | 50 | } as unknown as WsRpcProtocolClient; |
| 54 | const session: RpcSession = { | |
| 51 | const session: RpcSession.RpcSession = { | |
| 55 | 52 | client, |
| 56 | 53 | initialConfig: Effect.never, |
| 57 | 54 | ready: Effect.void, |
| 58 | 55 | probe: Effect.void, |
| 59 | 56 | closed: Effect.never, |
| 60 | 57 | }; |
| 61 | return EnvironmentSupervisor.of({ | |
| 58 | return EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 62 | 59 | target: TARGET, |
| 63 | 60 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), |
| 64 | 61 | session: yield* SubscriptionRef.make(Option.some(session)), |
| ... | ... | @@ -66,7 +63,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct |
| 66 | 63 | connect: Effect.void, |
| 67 | 64 | disconnect: Effect.void, |
| 68 | 65 | retryNow: Effect.void, |
| 69 | } satisfies EnvironmentSupervisorService); | |
| 66 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | |
| 70 | 67 | }); |
| 71 | 68 | |
| 72 | 69 | describe("environment commands", () => { |
| ... | ... | @@ -80,7 +77,7 @@ describe("environment commands", () => { |
| 80 | 77 | title: "Project", |
| 81 | 78 | workspaceRoot: "/workspace/project", |
| 82 | 79 | createdAt: "2026-06-06T00:00:00.000Z", |
| 83 | }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); | |
| 80 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | |
| 84 | 81 | |
| 85 | 82 | expect(result).toEqual({ sequence: 1 }); |
| 86 | 83 | expect(dispatched).toEqual([ |
| ... | ... | @@ -105,7 +102,7 @@ describe("environment commands", () => { |
| 105 | 102 | commandId: CommandId.make("queued-command"), |
| 106 | 103 | threadId: ThreadId.make("thread-1"), |
| 107 | 104 | createdAt: "2026-06-06T00:01:00.000Z", |
| 108 | }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); | |
| 105 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | |
| 109 | 106 | |
| 110 | 107 | expect(dispatched).toEqual([ |
| 111 | 108 | { |
| ... | ... | @@ -126,7 +123,7 @@ describe("environment commands", () => { |
| 126 | 123 | yield* archiveThread({ |
| 127 | 124 | commandId: CommandId.make("archive-command"), |
| 128 | 125 | threadId: ThreadId.make("thread-1"), |
| 129 | }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); | |
| 126 | }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); | |
| 130 | 127 | |
| 131 | 128 | expect(dispatched).toEqual([ |
| 132 | 129 | { |
packages/client-runtime/src/platform/storageDocument.test.ts+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 3 | |
| 4 | import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; | |
| 4 | import * as TokenStore from "../authorization/tokenStore.ts"; | |
| 5 | 5 | import { |
| 6 | 6 | BearerConnectionCredential, |
| 7 | 7 | BearerConnectionProfile, |
| ... | ... | @@ -38,7 +38,7 @@ const BEARER_PROFILE = new BearerConnectionProfile({ |
| 38 | 38 | const BEARER_CREDENTIAL = new BearerConnectionCredential({ |
| 39 | 39 | token: "bearer-token", |
| 40 | 40 | }); |
| 41 | const REMOTE_TOKEN = new RemoteDpopAccessToken({ | |
| 41 | const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ | |
| 42 | 42 | environmentId: ENVIRONMENT_ID, |
| 43 | 43 | label: "Remote", |
| 44 | 44 | endpoint: { |
packages/client-runtime/src/platform/storageDocument.ts+2-2| ... | ... | @@ -6,7 +6,7 @@ import { |
| 6 | 6 | ConnectionProfile, |
| 7 | 7 | } from "../connection/catalog.ts"; |
| 8 | 8 | import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; |
| 9 | import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; | |
| 9 | import * as TokenStore from "../authorization/tokenStore.ts"; | |
| 10 | 10 | |
| 11 | 11 | export const StoredConnectionCredential = Schema.Struct({ |
| 12 | 12 | connectionId: Schema.String, |
| ... | ... | @@ -19,7 +19,7 @@ export const ConnectionCatalogDocument = Schema.Struct({ |
| 19 | 19 | targets: Schema.Array(PersistedConnectionTarget), |
| 20 | 20 | profiles: Schema.Array(ConnectionProfile), |
| 21 | 21 | credentials: Schema.Array(StoredConnectionCredential), |
| 22 | remoteDpopTokens: Schema.Array(RemoteDpopAccessToken), | |
| 22 | remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), | |
| 23 | 23 | }); |
| 24 | 24 | export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; |
| 25 | 25 |
packages/client-runtime/src/relay/discovery.test.ts+43-39| ... | ... | @@ -13,17 +13,12 @@ import * as Ref from "effect/Ref"; |
| 13 | 13 | import * as Stream from "effect/Stream"; |
| 14 | 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 15 | 15 | |
| 16 | import { | |
| 17 | ManagedRelayClient, | |
| 18 | ManagedRelayClientError, | |
| 19 | ManagedRelayRequestTimeoutError, | |
| 20 | type ManagedRelayClientShape, | |
| 21 | } from "./managedRelay.ts"; | |
| 22 | import { CloudSession } from "../platform/capabilities.ts"; | |
| 23 | import { Connectivity } from "../connection/connectivity.ts"; | |
| 16 | import * as ManagedRelay from "./managedRelay.ts"; | |
| 17 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 18 | import * as Connectivity from "../connection/connectivity.ts"; | |
| 24 | 19 | import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts"; |
| 25 | import { ConnectionWakeups } from "../connection/wakeups.ts"; | |
| 26 | import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts"; | |
| 20 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | |
| 21 | import * as RelayEnvironmentDiscovery from "./discovery.ts"; | |
| 27 | 22 | |
| 28 | 23 | const environments = [ |
| 29 | 24 | { |
| ... | ... | @@ -63,7 +58,7 @@ function status( |
| 63 | 58 | const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { |
| 64 | 59 | const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online"); |
| 65 | 60 | const listCalls = yield* Ref.make(0); |
| 66 | const listFailure = yield* Ref.make<ManagedRelayClientError | null>(null); | |
| 61 | const listFailure = yield* Ref.make<ManagedRelay.ManagedRelayClientError | null>(null); | |
| 67 | 62 | const secondListCall = yield* Deferred.make<void>(); |
| 68 | 63 | const clerkToken = yield* Ref.make<string | null>("clerk-token"); |
| 69 | 64 | const wakeups = yield* SubscriptionRef.make<{ |
| ... | ... | @@ -74,10 +69,16 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { |
| 74 | 69 | reason: "application-active", |
| 75 | 70 | }); |
| 76 | 71 | const statusRequests = yield* Ref.make( |
| 77 | new Map<string, Deferred.Deferred<RelayEnvironmentStatusResponse, ManagedRelayClientError>>(), | |
| 72 | new Map< | |
| 73 | string, | |
| 74 | Deferred.Deferred<RelayEnvironmentStatusResponse, ManagedRelay.ManagedRelayClientError> | |
| 75 | >(), | |
| 78 | 76 | ); |
| 79 | 77 | for (const environment of environments) { |
| 80 | const request = yield* Deferred.make<RelayEnvironmentStatusResponse, ManagedRelayClientError>(); | |
| 78 | const request = yield* Deferred.make< | |
| 79 | RelayEnvironmentStatusResponse, | |
| 80 | ManagedRelay.ManagedRelayClientError | |
| 81 | >(); | |
| 81 | 82 | yield* Ref.update(statusRequests, (current) => { |
| 82 | 83 | const next = new Map(current); |
| 83 | 84 | next.set(environment.environmentId, request); |
| ... | ... | @@ -85,7 +86,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { |
| 85 | 86 | }); |
| 86 | 87 | } |
| 87 | 88 | |
| 88 | const client = ManagedRelayClient.of({ | |
| 89 | const client = ManagedRelay.ManagedRelayClient.of({ | |
| 89 | 90 | relayUrl: "https://relay.example.test", |
| 90 | 91 | listEnvironments: () => |
| 91 | 92 | Effect.gen(function* () { |
| ... | ... | @@ -112,25 +113,25 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { |
| 112 | 113 | unregisterDevice: () => Effect.die("unused"), |
| 113 | 114 | registerLiveActivity: () => Effect.die("unused"), |
| 114 | 115 | resetTokenCache: Effect.void, |
| 115 | } satisfies ManagedRelayClientShape); | |
| 116 | const connectivity = Connectivity.of({ | |
| 116 | } satisfies ManagedRelay.ManagedRelayClient["Service"]); | |
| 117 | const connectivity = Connectivity.Connectivity.of({ | |
| 117 | 118 | status: SubscriptionRef.get(networkStatus), |
| 118 | 119 | changes: SubscriptionRef.changes(networkStatus), |
| 119 | 120 | }); |
| 120 | const layer = relayEnvironmentDiscoveryLayer.pipe( | |
| 121 | const layer = RelayEnvironmentDiscovery.layer.pipe( | |
| 121 | 122 | Layer.provide( |
| 122 | 123 | Layer.mergeAll( |
| 123 | Layer.succeed(ManagedRelayClient, client), | |
| 124 | Layer.succeed(ManagedRelay.ManagedRelayClient, client), | |
| 124 | 125 | Layer.succeed( |
| 125 | CloudSession, | |
| 126 | CloudSession.of({ | |
| 126 | ClientCapabilities.CloudSession, | |
| 127 | ClientCapabilities.CloudSession.of({ | |
| 127 | 128 | clerkToken: Ref.get(clerkToken).pipe( |
| 128 | 129 | Effect.flatMap((token) => |
| 129 | 130 | token === null |
| 130 | 131 | ? Effect.fail( |
| 131 | 132 | new ConnectionBlockedError({ |
| 132 | 133 | reason: "authentication", |
| 133 | message: "Signed out.", | |
| 134 | detail: "Signed out.", | |
| 134 | 135 | }), |
| 135 | 136 | ) |
| 136 | 137 | : Effect.succeed(token), |
| ... | ... | @@ -138,10 +139,10 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { |
| 138 | 139 | ), |
| 139 | 140 | }), |
| 140 | 141 | ), |
| 141 | Layer.succeed(Connectivity, connectivity), | |
| 142 | Layer.succeed(Connectivity.Connectivity, connectivity), | |
| 142 | 143 | Layer.succeed( |
| 143 | ConnectionWakeups, | |
| 144 | ConnectionWakeups.of({ | |
| 144 | ConnectionWakeups.ConnectionWakeups, | |
| 145 | ConnectionWakeups.ConnectionWakeups.of({ | |
| 145 | 146 | changes: SubscriptionRef.changes(wakeups).pipe( |
| 146 | 147 | Stream.drop(1), |
| 147 | 148 | Stream.map((event) => event.reason), |
| ... | ... | @@ -173,7 +174,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 173 | 174 | Effect.gen(function* () { |
| 174 | 175 | const harness = yield* makeHarness(); |
| 175 | 176 | yield* Effect.gen(function* () { |
| 176 | const discovery = yield* RelayEnvironmentDiscovery; | |
| 177 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | |
| 177 | 178 | const refreshFiber = yield* Effect.forkChild(discovery.refresh); |
| 178 | 179 | |
| 179 | 180 | const checking = yield* SubscriptionRef.changes(discovery.state).pipe( |
| ... | ... | @@ -224,7 +225,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 224 | 225 | Effect.gen(function* () { |
| 225 | 226 | const harness = yield* makeHarness(); |
| 226 | 227 | yield* Effect.gen(function* () { |
| 227 | const discovery = yield* RelayEnvironmentDiscovery; | |
| 228 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | |
| 228 | 229 | const requests = yield* Ref.get(harness.statusRequests); |
| 229 | 230 | for (const environment of environments) { |
| 230 | 231 | yield* Deferred.succeed( |
| ... | ... | @@ -253,13 +254,13 @@ describe("RelayEnvironmentDiscovery", () => { |
| 253 | 254 | it.effect("publishes listing failures without rejecting the refresh command", () => |
| 254 | 255 | Effect.gen(function* () { |
| 255 | 256 | const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online"); |
| 256 | const client = ManagedRelayClient.of({ | |
| 257 | const client = ManagedRelay.ManagedRelayClient.of({ | |
| 257 | 258 | relayUrl: "https://relay.example.test", |
| 258 | 259 | listEnvironments: () => |
| 259 | 260 | Effect.fail( |
| 260 | new ManagedRelayClientError({ | |
| 261 | new ManagedRelay.ManagedRelayClientError({ | |
| 261 | 262 | message: "Relay environment listing timed out.", |
| 262 | cause: new ManagedRelayRequestTimeoutError({ | |
| 263 | cause: new ManagedRelay.ManagedRelayRequestTimeoutError({ | |
| 263 | 264 | message: "Relay environment listing timed out.", |
| 264 | 265 | }), |
| 265 | 266 | }), |
| ... | ... | @@ -274,25 +275,28 @@ describe("RelayEnvironmentDiscovery", () => { |
| 274 | 275 | unregisterDevice: () => Effect.die("unused"), |
| 275 | 276 | registerLiveActivity: () => Effect.die("unused"), |
| 276 | 277 | resetTokenCache: Effect.void, |
| 277 | } satisfies ManagedRelayClientShape); | |
| 278 | const layer = relayEnvironmentDiscoveryLayer.pipe( | |
| 278 | } satisfies ManagedRelay.ManagedRelayClient["Service"]); | |
| 279 | const layer = RelayEnvironmentDiscovery.layer.pipe( | |
| 279 | 280 | Layer.provide( |
| 280 | 281 | Layer.mergeAll( |
| 281 | Layer.succeed(ManagedRelayClient, client), | |
| 282 | Layer.succeed(CloudSession, { | |
| 282 | Layer.succeed(ManagedRelay.ManagedRelayClient, client), | |
| 283 | Layer.succeed(ClientCapabilities.CloudSession, { | |
| 283 | 284 | clerkToken: Effect.succeed("clerk-token"), |
| 284 | 285 | }), |
| 285 | Layer.succeed(Connectivity, { | |
| 286 | Layer.succeed(Connectivity.Connectivity, { | |
| 286 | 287 | status: SubscriptionRef.get(networkStatus), |
| 287 | 288 | changes: SubscriptionRef.changes(networkStatus), |
| 288 | 289 | }), |
| 289 | Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), | |
| 290 | Layer.succeed( | |
| 291 | ConnectionWakeups.ConnectionWakeups, | |
| 292 | ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }), | |
| 293 | ), | |
| 290 | 294 | ), |
| 291 | 295 | ), |
| 292 | 296 | ); |
| 293 | 297 | |
| 294 | 298 | yield* Effect.gen(function* () { |
| 295 | const discovery = yield* RelayEnvironmentDiscovery; | |
| 299 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | |
| 296 | 300 | yield* discovery.refresh; |
| 297 | 301 | |
| 298 | 302 | const state = yield* SubscriptionRef.get(discovery.state); |
| ... | ... | @@ -310,7 +314,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 310 | 314 | Effect.gen(function* () { |
| 311 | 315 | const harness = yield* makeHarness(); |
| 312 | 316 | yield* Effect.gen(function* () { |
| 313 | const discovery = yield* RelayEnvironmentDiscovery; | |
| 317 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | |
| 314 | 318 | const requests = yield* Ref.get(harness.statusRequests); |
| 315 | 319 | for (const environment of environments) { |
| 316 | 320 | yield* Deferred.succeed( |
| ... | ... | @@ -323,7 +327,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 323 | 327 | |
| 324 | 328 | yield* Ref.set( |
| 325 | 329 | harness.listFailure, |
| 326 | new ManagedRelayClientError({ | |
| 330 | new ManagedRelay.ManagedRelayClientError({ | |
| 327 | 331 | message: "Relay environment listing failed.", |
| 328 | 332 | }), |
| 329 | 333 | ); |
| ... | ... | @@ -340,7 +344,7 @@ describe("RelayEnvironmentDiscovery", () => { |
| 340 | 344 | Effect.gen(function* () { |
| 341 | 345 | const harness = yield* makeHarness(); |
| 342 | 346 | yield* Effect.gen(function* () { |
| 343 | const discovery = yield* RelayEnvironmentDiscovery; | |
| 347 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | |
| 344 | 348 | const refreshFiber = yield* Effect.forkChild(discovery.refresh); |
| 345 | 349 | yield* SubscriptionRef.changes(discovery.state).pipe( |
| 346 | 350 | Stream.filter((state) => state.environments.size === environments.length), |
packages/client-runtime/src/relay/discovery.ts+17-22| ... | ... | @@ -16,12 +16,12 @@ import * as Semaphore from "effect/Semaphore"; |
| 16 | 16 | import * as Stream from "effect/Stream"; |
| 17 | 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 18 | 18 | |
| 19 | import { ManagedRelayClient } from "./managedRelay.ts"; | |
| 20 | import { CloudSession } from "../platform/capabilities.ts"; | |
| 21 | import { Connectivity } from "../connection/connectivity.ts"; | |
| 19 | import * as ManagedRelay from "./managedRelay.ts"; | |
| 20 | import * as ClientCapabilities from "../platform/capabilities.ts"; | |
| 21 | import * as Connectivity from "../connection/connectivity.ts"; | |
| 22 | 22 | import { mapManagedRelayError } from "../connection/errors.ts"; |
| 23 | 23 | import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; |
| 24 | import { ConnectionWakeups } from "../connection/wakeups.ts"; | |
| 24 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | |
| 25 | 25 | |
| 26 | 26 | export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error"; |
| 27 | 27 | |
| ... | ... | @@ -39,14 +39,12 @@ export interface RelayEnvironmentDiscoveryState { |
| 39 | 39 | readonly error: Option.Option<ConnectionAttemptError>; |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | export interface RelayEnvironmentDiscoveryService { | |
| 43 | readonly state: SubscriptionRef.SubscriptionRef<RelayEnvironmentDiscoveryState>; | |
| 44 | readonly refresh: Effect.Effect<void>; | |
| 45 | } | |
| 46 | ||
| 47 | 42 | export class RelayEnvironmentDiscovery extends Context.Service< |
| 48 | 43 | RelayEnvironmentDiscovery, |
| 49 | RelayEnvironmentDiscoveryService | |
| 44 | { | |
| 45 | readonly state: SubscriptionRef.SubscriptionRef<RelayEnvironmentDiscoveryState>; | |
| 46 | readonly refresh: Effect.Effect<void>; | |
| 47 | } | |
| 50 | 48 | >()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {} |
| 51 | 49 | |
| 52 | 50 | export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { |
| ... | ... | @@ -64,7 +62,7 @@ function validateStatus( |
| 64 | 62 | return Effect.fail( |
| 65 | 63 | new ConnectionBlockedError({ |
| 66 | 64 | reason: "configuration", |
| 67 | message: "Relay returned status for a different environment.", | |
| 65 | detail: "Relay returned status for a different environment.", | |
| 68 | 66 | }), |
| 69 | 67 | ); |
| 70 | 68 | } |
| ... | ... | @@ -76,7 +74,7 @@ function validateStatus( |
| 76 | 74 | return Effect.fail( |
| 77 | 75 | new ConnectionBlockedError({ |
| 78 | 76 | reason: "configuration", |
| 79 | message: "Relay returned status for a different environment endpoint.", | |
| 77 | detail: "Relay returned status for a different environment endpoint.", | |
| 80 | 78 | }), |
| 81 | 79 | ); |
| 82 | 80 | } |
| ... | ... | @@ -87,7 +85,7 @@ function validateStatus( |
| 87 | 85 | return Effect.fail( |
| 88 | 86 | new ConnectionBlockedError({ |
| 89 | 87 | reason: "configuration", |
| 90 | message: "Relay returned a descriptor for a different environment.", | |
| 88 | detail: "Relay returned a descriptor for a different environment.", | |
| 91 | 89 | }), |
| 92 | 90 | ); |
| 93 | 91 | } |
| ... | ... | @@ -104,11 +102,11 @@ function relayAccountId(clerkToken: string): Option.Option<string> { |
| 104 | 102 | } |
| 105 | 103 | } |
| 106 | 104 | |
| 107 | const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { | |
| 108 | const relay = yield* ManagedRelayClient; | |
| 109 | const session = yield* CloudSession; | |
| 110 | const connectivity = yield* Connectivity; | |
| 111 | const wakeups = yield* ConnectionWakeups; | |
| 105 | export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { | |
| 106 | const relay = yield* ManagedRelay.ManagedRelayClient; | |
| 107 | const session = yield* ClientCapabilities.CloudSession; | |
| 108 | const connectivity = yield* Connectivity.Connectivity; | |
| 109 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; | |
| 112 | 110 | const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); |
| 113 | 111 | const refreshLock = yield* Semaphore.make(1); |
| 114 | 112 | const hasRefreshed = yield* Ref.make(false); |
| ... | ... | @@ -327,7 +325,4 @@ const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make" |
| 327 | 325 | return RelayEnvironmentDiscovery.of({ state, refresh }); |
| 328 | 326 | }); |
| 329 | 327 | |
| 330 | export const relayEnvironmentDiscoveryLayer = Layer.effect( | |
| 331 | RelayEnvironmentDiscovery, | |
| 332 | makeRelayEnvironmentDiscovery(), | |
| 333 | ); | |
| 328 | export const layer = Layer.effect(RelayEnvironmentDiscovery, make()); |
packages/client-runtime/src/rpc/client.test.ts+16-17| ... | ... | @@ -22,11 +22,8 @@ import { |
| 22 | 22 | type PreparedConnection, |
| 23 | 23 | type SupervisorConnectionState, |
| 24 | 24 | } from "../connection/model.ts"; |
| 25 | import { | |
| 26 | EnvironmentSupervisor, | |
| 27 | type EnvironmentSupervisorService, | |
| 28 | } from "../connection/supervisor.ts"; | |
| 29 | import type { RpcSession } from "../rpc/session.ts"; | |
| 25 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 26 | import * as RpcSession from "../rpc/session.ts"; | |
| 30 | 27 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 31 | 28 | import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; |
| 32 | 29 | |
| ... | ... | @@ -46,7 +43,7 @@ const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { |
| 46 | 43 | stage: "downloading", |
| 47 | 44 | }; |
| 48 | 45 | |
| 49 | function session(client: WsRpcProtocolClient): RpcSession { | |
| 46 | function session(client: WsRpcProtocolClient): RpcSession.RpcSession { | |
| 50 | 47 | return { |
| 51 | 48 | client, |
| 52 | 49 | initialConfig: Effect.never, |
| ... | ... | @@ -58,10 +55,12 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 58 | 55 | |
| 59 | 56 | const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { |
| 60 | 57 | const state = yield* SubscriptionRef.make<SupervisorConnectionState>(AVAILABLE_CONNECTION_STATE); |
| 61 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession>>(Option.none()); | |
| 58 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | |
| 59 | Option.none(), | |
| 60 | ); | |
| 62 | 61 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none()); |
| 63 | 62 | const retryCount = yield* Ref.make(0); |
| 64 | const supervisor = EnvironmentSupervisor.of({ | |
| 63 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 65 | 64 | target: TARGET, |
| 66 | 65 | state, |
| 67 | 66 | session: activeSession, |
| ... | ... | @@ -69,7 +68,7 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { |
| 69 | 68 | connect: Effect.void, |
| 70 | 69 | disconnect: Effect.void, |
| 71 | 70 | retryNow: Ref.update(retryCount, (count) => count + 1), |
| 72 | } satisfies EnvironmentSupervisorService); | |
| 71 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | |
| 73 | 72 | return { |
| 74 | 73 | activeSession, |
| 75 | 74 | retryCount, |
| ... | ... | @@ -89,7 +88,7 @@ describe("environment RPC", () => { |
| 89 | 88 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); |
| 90 | 89 | |
| 91 | 90 | const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( |
| 92 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 91 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 93 | 92 | Effect.provideService( |
| 94 | 93 | EnvironmentRpcRequestObserver, |
| 95 | 94 | EnvironmentRpcRequestObserver.of({ |
| ... | ... | @@ -128,7 +127,7 @@ describe("environment RPC", () => { |
| 128 | 127 | const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( |
| 129 | 128 | Stream.take(2), |
| 130 | 129 | Stream.runCollect, |
| 131 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 130 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 132 | 131 | Effect.forkChild, |
| 133 | 132 | ); |
| 134 | 133 | yield* Effect.yieldNow; |
| ... | ... | @@ -172,7 +171,7 @@ describe("environment RPC", () => { |
| 172 | 171 | |
| 173 | 172 | const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( |
| 174 | 173 | Stream.runDrain, |
| 175 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 174 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 176 | 175 | Effect.forkChild, |
| 177 | 176 | ); |
| 178 | 177 | yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); |
| ... | ... | @@ -212,7 +211,7 @@ describe("environment RPC", () => { |
| 212 | 211 | |
| 213 | 212 | const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( |
| 214 | 213 | Stream.runDrain, |
| 215 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 214 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 216 | 215 | Effect.forkChild, |
| 217 | 216 | ); |
| 218 | 217 | yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); |
| ... | ... | @@ -243,7 +242,7 @@ describe("environment RPC", () => { |
| 243 | 242 | yield* SubscriptionRef.set(activeSession, Option.some(session(client))); |
| 244 | 243 | const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( |
| 245 | 244 | Stream.runDrain, |
| 246 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 245 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 247 | 246 | Effect.flip, |
| 248 | 247 | ); |
| 249 | 248 | |
| ... | ... | @@ -283,7 +282,7 @@ describe("environment RPC", () => { |
| 283 | 282 | }, |
| 284 | 283 | ).pipe( |
| 285 | 284 | Stream.runDrain, |
| 286 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 285 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 287 | 286 | Effect.forkChild, |
| 288 | 287 | ); |
| 289 | 288 | for (let attempt = 0; attempt < 100 && observedFailures.length < 1; attempt += 1) { |
| ... | ... | @@ -329,7 +328,7 @@ describe("environment RPC", () => { |
| 329 | 328 | }, |
| 330 | 329 | ).pipe( |
| 331 | 330 | Stream.runDrain, |
| 332 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 331 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 333 | 332 | Effect.forkChild, |
| 334 | 333 | ); |
| 335 | 334 | for (let attempt = 0; attempt < 100; attempt += 1) { |
| ... | ... | @@ -377,7 +376,7 @@ describe("environment RPC", () => { |
| 377 | 376 | }, |
| 378 | 377 | ).pipe( |
| 379 | 378 | Stream.runDrain, |
| 380 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 379 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 381 | 380 | Effect.exit, |
| 382 | 381 | ); |
| 383 | 382 |
packages/client-runtime/src/rpc/index.ts+1-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | 1 | export * from "./client.ts"; |
| 2 | 2 | export * from "./http.ts"; |
| 3 | 3 | export * from "./protocol.ts"; |
| 4 | export * from "./session.ts"; | |
| 4 | export { type RpcSession, RpcSessionFactory } from "./session.ts"; |
packages/client-runtime/src/rpc/session.test.ts+3-3| ... | ... | @@ -18,7 +18,7 @@ import { |
| 18 | 18 | PrimaryConnectionTarget, |
| 19 | 19 | type PreparedConnection, |
| 20 | 20 | } from "../connection/model.ts"; |
| 21 | import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts"; | |
| 21 | import * as RpcSession from "./session.ts"; | |
| 22 | 22 | |
| 23 | 23 | type SocketEventType = "open" | "message" | "close" | "error"; |
| 24 | 24 | type SocketEvent = { |
| ... | ... | @@ -149,8 +149,8 @@ const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { |
| 149 | 149 | sockets.push(socket); |
| 150 | 150 | return socket as unknown as globalThis.WebSocket; |
| 151 | 151 | }); |
| 152 | const layer = rpcSessionFactoryLayer.pipe(Layer.provide(constructorLayer)); | |
| 153 | const factory = yield* RpcSessionFactory.pipe(Effect.provide(layer)); | |
| 152 | const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); | |
| 153 | const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); | |
| 154 | 154 | return { factory, sockets }; |
| 155 | 155 | }); |
| 156 | 156 |
packages/client-runtime/src/rpc/session.ts+74-74| ... | ... | @@ -5,7 +5,8 @@ import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Schedule from "effect/Schedule"; |
| 7 | 7 | import type * as Scope from "effect/Scope"; |
| 8 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | |
| 8 | import * as RpcClient from "effect/unstable/rpc/RpcClient"; | |
| 9 | import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; | |
| 9 | 10 | import * as Socket from "effect/unstable/socket/Socket"; |
| 10 | 11 | |
| 11 | 12 | import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; |
| ... | ... | @@ -47,98 +48,97 @@ function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptErro |
| 47 | 48 | case "EnvironmentAuthorizationError": |
| 48 | 49 | return new ConnectionBlockedError({ |
| 49 | 50 | reason: "permission", |
| 50 | message: error.message, | |
| 51 | detail: error.message, | |
| 51 | 52 | }); |
| 52 | 53 | case "KeybindingsConfigParseError": |
| 53 | 54 | case "ServerSettingsError": |
| 54 | 55 | return new ConnectionTransientErrorClass({ |
| 55 | 56 | reason: "remote-unavailable", |
| 56 | message: error.message, | |
| 57 | detail: error.message, | |
| 57 | 58 | }); |
| 58 | 59 | case "RpcClientError": |
| 59 | 60 | return new ConnectionTransientErrorClass({ |
| 60 | 61 | reason: "transport", |
| 61 | message: error.message, | |
| 62 | detail: error.message, | |
| 62 | 63 | }); |
| 63 | 64 | } |
| 64 | 65 | } |
| 65 | 66 | |
| 66 | export const rpcSessionFactoryLayer = Layer.effect( | |
| 67 | RpcSessionFactory, | |
| 68 | Effect.gen(function* () { | |
| 69 | const webSocketConstructor = yield* Socket.WebSocketConstructor; | |
| 67 | export const make = Effect.gen(function* () { | |
| 68 | const webSocketConstructor = yield* Socket.WebSocketConstructor; | |
| 70 | 69 | |
| 71 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { | |
| 72 | yield* Effect.annotateCurrentSpan({ | |
| 73 | "connection.environment.id": connection.environmentId, | |
| 74 | }); | |
| 70 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { | |
| 71 | yield* Effect.annotateCurrentSpan({ | |
| 72 | "connection.environment.id": connection.environmentId, | |
| 73 | }); | |
| 75 | 74 | |
| 76 | const connected = yield* Deferred.make<void>(); | |
| 77 | const disconnected = yield* Deferred.make<never, ConnectionTransientError>(); | |
| 78 | const hooks = RpcClient.ConnectionHooks.of({ | |
| 79 | onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), | |
| 80 | onDisconnect: Deferred.isDone(connected).pipe( | |
| 81 | Effect.flatMap((wasConnected) => | |
| 82 | Deferred.fail( | |
| 83 | disconnected, | |
| 84 | new ConnectionTransientErrorClass({ | |
| 85 | reason: "transport", | |
| 86 | message: wasConnected | |
| 87 | ? `${connection.label} disconnected.` | |
| 88 | : `${connection.label} could not establish a WebSocket connection.`, | |
| 89 | }), | |
| 90 | ), | |
| 91 | ), | |
| 92 | Effect.asVoid, | |
| 93 | ), | |
| 94 | }); | |
| 95 | const socketLayer = Socket.layerWebSocket(connection.socketUrl, { | |
| 96 | openTimeout: SOCKET_OPEN_TIMEOUT, | |
| 97 | }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); | |
| 98 | const protocolLayer = Layer.effect( | |
| 99 | RpcClient.Protocol, | |
| 100 | RpcClient.makeProtocolSocket({ | |
| 101 | retryTransientErrors: false, | |
| 102 | retryPolicy: Schedule.recurs(0), | |
| 103 | }), | |
| 104 | ).pipe( | |
| 105 | Layer.provide( | |
| 106 | Layer.mergeAll( | |
| 107 | socketLayer, | |
| 108 | RpcSerialization.layerJson, | |
| 109 | Layer.succeed(RpcClient.ConnectionHooks, hooks), | |
| 75 | const connected = yield* Deferred.make<void>(); | |
| 76 | const disconnected = yield* Deferred.make<never, ConnectionTransientError>(); | |
| 77 | const hooks = RpcClient.ConnectionHooks.of({ | |
| 78 | onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), | |
| 79 | onDisconnect: Deferred.isDone(connected).pipe( | |
| 80 | Effect.flatMap((wasConnected) => | |
| 81 | Deferred.fail( | |
| 82 | disconnected, | |
| 83 | new ConnectionTransientErrorClass({ | |
| 84 | reason: "transport", | |
| 85 | detail: wasConnected | |
| 86 | ? `${connection.label} disconnected.` | |
| 87 | : `${connection.label} could not establish a WebSocket connection.`, | |
| 88 | }), | |
| 110 | 89 | ), |
| 111 | 90 | ), |
| 112 | ); | |
| 113 | const protocolContext = yield* Layer.build(protocolLayer).pipe( | |
| 114 | Effect.withSpan("environment.websocket.connect"), | |
| 115 | ); | |
| 116 | const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); | |
| 117 | const initialConfig = yield* Effect.cached( | |
| 118 | client[WS_METHODS.serverGetConfig]({}).pipe( | |
| 119 | Effect.mapError(mapInitialConfigError), | |
| 120 | Effect.withSpan("environment.initialSync"), | |
| 91 | Effect.asVoid, | |
| 92 | ), | |
| 93 | }); | |
| 94 | const socketLayer = Socket.layerWebSocket(connection.socketUrl, { | |
| 95 | openTimeout: SOCKET_OPEN_TIMEOUT, | |
| 96 | }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); | |
| 97 | const protocolLayer = Layer.effect( | |
| 98 | RpcClient.Protocol, | |
| 99 | RpcClient.makeProtocolSocket({ | |
| 100 | retryTransientErrors: false, | |
| 101 | retryPolicy: Schedule.recurs(0), | |
| 102 | }), | |
| 103 | ).pipe( | |
| 104 | Layer.provide( | |
| 105 | Layer.mergeAll( | |
| 106 | socketLayer, | |
| 107 | RpcSerialization.layerJson, | |
| 108 | Layer.succeed(RpcClient.ConnectionHooks, hooks), | |
| 121 | 109 | ), |
| 122 | ); | |
| 123 | const probe = client[WS_METHODS.serverGetConfig]({}).pipe( | |
| 110 | ), | |
| 111 | ); | |
| 112 | const protocolContext = yield* Layer.build(protocolLayer).pipe( | |
| 113 | Effect.withSpan("environment.websocket.connect"), | |
| 114 | ); | |
| 115 | const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); | |
| 116 | const initialConfig = yield* Effect.cached( | |
| 117 | client[WS_METHODS.serverGetConfig]({}).pipe( | |
| 124 | 118 | Effect.mapError(mapInitialConfigError), |
| 119 | Effect.withSpan("environment.initialSync"), | |
| 120 | ), | |
| 121 | ); | |
| 122 | const probe = client[WS_METHODS.serverGetConfig]({}).pipe( | |
| 123 | Effect.mapError(mapInitialConfigError), | |
| 124 | Effect.asVoid, | |
| 125 | Effect.withSpan("clientRuntime.connection.rpcSession.probe"), | |
| 126 | ); | |
| 127 | ||
| 128 | return { | |
| 129 | client, | |
| 130 | initialConfig, | |
| 131 | ready: Deferred.await(connected).pipe( | |
| 132 | Effect.andThen(initialConfig), | |
| 125 | 133 | Effect.asVoid, |
| 126 | Effect.withSpan("clientRuntime.connection.rpcSession.probe"), | |
| 127 | ); | |
| 134 | Effect.raceFirst(Deferred.await(disconnected)), | |
| 135 | ), | |
| 136 | probe, | |
| 137 | closed: Deferred.await(disconnected), | |
| 138 | } satisfies RpcSession; | |
| 139 | }); | |
| 128 | 140 | |
| 129 | return { | |
| 130 | client, | |
| 131 | initialConfig, | |
| 132 | ready: Deferred.await(connected).pipe( | |
| 133 | Effect.andThen(initialConfig), | |
| 134 | Effect.asVoid, | |
| 135 | Effect.raceFirst(Deferred.await(disconnected)), | |
| 136 | ), | |
| 137 | probe, | |
| 138 | closed: Deferred.await(disconnected), | |
| 139 | } satisfies RpcSession; | |
| 140 | }); | |
| 141 | return RpcSessionFactory.of({ connect }); | |
| 142 | }); | |
| 141 | 143 | |
| 142 | return RpcSessionFactory.of({ connect }); | |
| 143 | }), | |
| 144 | ); | |
| 144 | export const layer = Layer.effect(RpcSessionFactory, make); |
packages/client-runtime/src/state/connections.ts+21-11| ... | ... | @@ -5,10 +5,10 @@ import * as Stream from "effect/Stream"; |
| 5 | 5 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 6 | 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 7 | 7 | |
| 8 | import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts"; | |
| 8 | import * as EnvironmentRegistry from "../connection/registry.ts"; | |
| 9 | 9 | import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; |
| 10 | 10 | import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; |
| 11 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | |
| 11 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 12 | 12 | import { |
| 13 | 13 | createAtomCommandScheduler, |
| 14 | 14 | createRuntimeCommand, |
| ... | ... | @@ -26,13 +26,13 @@ export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.f |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | 28 | export function createEnvironmentCatalogAtoms<R, E>( |
| 29 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 29 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>, | |
| 30 | 30 | ) { |
| 31 | 31 | const commandScheduler = createAtomCommandScheduler(); |
| 32 | 32 | const serial = { mode: "serial" as const, key: () => "environment-catalog" }; |
| 33 | 33 | const catalogAtom = runtime.atom( |
| 34 | 34 | Stream.unwrap( |
| 35 | EnvironmentRegistry.pipe( | |
| 35 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 36 | 36 | Effect.map((registry) => |
| 37 | 37 | SubscriptionRef.changes(registry.entries).pipe( |
| 38 | 38 | Stream.map((entries) => ({ |
| ... | ... | @@ -52,7 +52,7 @@ export function createEnvironmentCatalogAtoms<R, E>( |
| 52 | 52 | |
| 53 | 53 | const networkStatusAtom = runtime.atom( |
| 54 | 54 | Stream.unwrap( |
| 55 | EnvironmentRegistry.pipe( | |
| 55 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 56 | 56 | Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)), |
| 57 | 57 | ), |
| 58 | 58 | ), |
| ... | ... | @@ -68,7 +68,7 @@ export function createEnvironmentCatalogAtoms<R, E>( |
| 68 | 68 | followStreamInEnvironment( |
| 69 | 69 | environmentId, |
| 70 | 70 | Stream.unwrap( |
| 71 | EnvironmentSupervisor.pipe( | |
| 71 | EnvironmentSupervisor.EnvironmentSupervisor.pipe( | |
| 72 | 72 | Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), |
| 73 | 73 | ), |
| 74 | 74 | ), |
| ... | ... | @@ -81,29 +81,39 @@ export function createEnvironmentCatalogAtoms<R, E>( |
| 81 | 81 | label: "environment-catalog:register", |
| 82 | 82 | scheduler: commandScheduler, |
| 83 | 83 | concurrency: serial, |
| 84 | execute: (target: Parameters<EnvironmentRegistryService["register"]>[0]) => | |
| 85 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.register(target))), | |
| 84 | execute: ( | |
| 85 | target: Parameters<EnvironmentRegistry.EnvironmentRegistry["Service"]["register"]>[0], | |
| 86 | ) => | |
| 87 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 88 | Effect.flatMap((registry) => registry.register(target)), | |
| 89 | ), | |
| 86 | 90 | }); |
| 87 | 91 | const remove = createRuntimeCommand(runtime, { |
| 88 | 92 | label: "environment-catalog:remove", |
| 89 | 93 | scheduler: commandScheduler, |
| 90 | 94 | concurrency: serial, |
| 91 | 95 | execute: (environmentId: EnvironmentIdType) => |
| 92 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.remove(environmentId))), | |
| 96 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 97 | Effect.flatMap((registry) => registry.remove(environmentId)), | |
| 98 | ), | |
| 93 | 99 | }); |
| 94 | 100 | const removeRelayEnvironments = createRuntimeCommand(runtime, { |
| 95 | 101 | label: "environment-catalog:remove-relay-environments", |
| 96 | 102 | scheduler: commandScheduler, |
| 97 | 103 | concurrency: serial, |
| 98 | 104 | execute: (_input: void) => |
| 99 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.removeRelayEnvironments())), | |
| 105 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 106 | Effect.flatMap((registry) => registry.removeRelayEnvironments()), | |
| 107 | ), | |
| 100 | 108 | }); |
| 101 | 109 | const retryNow = createRuntimeCommand(runtime, { |
| 102 | 110 | label: "environment-catalog:retry-now", |
| 103 | 111 | scheduler: commandScheduler, |
| 104 | 112 | concurrency: serial, |
| 105 | 113 | execute: (environmentId: EnvironmentIdType) => |
| 106 | EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.retryNow(environmentId))), | |
| 114 | EnvironmentRegistry.EnvironmentRegistry.pipe( | |
| 115 | Effect.flatMap((registry) => registry.retryNow(environmentId)), | |
| 116 | ), | |
| 107 | 117 | }); |
| 108 | 118 | |
| 109 | 119 | return { |
packages/client-runtime/src/state/shell-sync.test.ts+10-13| ... | ... | @@ -16,12 +16,9 @@ import { |
| 16 | 16 | PrimaryConnectionTarget, |
| 17 | 17 | type PreparedConnection, |
| 18 | 18 | } from "../connection/model.ts"; |
| 19 | import { | |
| 20 | EnvironmentSupervisor, | |
| 21 | type EnvironmentSupervisorService, | |
| 22 | } from "../connection/supervisor.ts"; | |
| 23 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | |
| 24 | import type { RpcSession } from "../rpc/session.ts"; | |
| 19 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 20 | import * as Persistence from "../platform/persistence.ts"; | |
| 21 | import * as RpcSession from "../rpc/session.ts"; | |
| 25 | 22 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 26 | 23 | import { makeEnvironmentShellState } from "./shell.ts"; |
| 27 | 24 | |
| ... | ... | @@ -39,7 +36,7 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { |
| 39 | 36 | updatedAt: "2026-06-06T00:00:00.000Z", |
| 40 | 37 | }; |
| 41 | 38 | |
| 42 | function session(client: WsRpcProtocolClient): RpcSession { | |
| 39 | function session(client: WsRpcProtocolClient): RpcSession.RpcSession { | |
| 43 | 40 | return { |
| 44 | 41 | client, |
| 45 | 42 | initialConfig: Effect.never, |
| ... | ... | @@ -57,10 +54,10 @@ describe("environment shell synchronization", () => { |
| 57 | 54 | [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), |
| 58 | 55 | } as unknown as WsRpcProtocolClient; |
| 59 | 56 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); |
| 60 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession>>( | |
| 57 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | |
| 61 | 58 | Option.some(session(client)), |
| 62 | 59 | ); |
| 63 | const supervisor = EnvironmentSupervisor.of({ | |
| 60 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 64 | 61 | target: TARGET, |
| 65 | 62 | state: supervisorState, |
| 66 | 63 | session: activeSession, |
| ... | ... | @@ -68,8 +65,8 @@ describe("environment shell synchronization", () => { |
| 68 | 65 | connect: Effect.void, |
| 69 | 66 | disconnect: Effect.void, |
| 70 | 67 | retryNow: Effect.void, |
| 71 | } satisfies EnvironmentSupervisorService); | |
| 72 | const cache = EnvironmentCacheStore.of({ | |
| 68 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | |
| 69 | const cache = Persistence.EnvironmentCacheStore.of({ | |
| 73 | 70 | loadShell: () => Effect.succeed(Option.none()), |
| 74 | 71 | saveShell: () => Effect.never, |
| 75 | 72 | loadThread: () => Effect.succeed(Option.none()), |
| ... | ... | @@ -78,8 +75,8 @@ describe("environment shell synchronization", () => { |
| 78 | 75 | clear: () => Effect.void, |
| 79 | 76 | }); |
| 80 | 77 | const shellState = yield* makeEnvironmentShellState().pipe( |
| 81 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 82 | Effect.provideService(EnvironmentCacheStore, cache), | |
| 78 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 79 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | |
| 83 | 80 | ); |
| 84 | 81 | |
| 85 | 82 | yield* SubscriptionRef.set(supervisorState, { |
packages/client-runtime/src/state/threads-sync.test.ts+10-13| ... | ... | @@ -24,12 +24,9 @@ import { |
| 24 | 24 | type PreparedConnection, |
| 25 | 25 | type SupervisorConnectionState, |
| 26 | 26 | } from "../connection/model.ts"; |
| 27 | import { | |
| 28 | EnvironmentSupervisor, | |
| 29 | type EnvironmentSupervisorService, | |
| 30 | } from "../connection/supervisor.ts"; | |
| 31 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | |
| 32 | import type { RpcSession } from "../rpc/session.ts"; | |
| 27 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 28 | import * as Persistence from "../platform/persistence.ts"; | |
| 29 | import * as RpcSession from "../rpc/session.ts"; | |
| 33 | 30 | import { |
| 34 | 31 | EMPTY_ENVIRONMENT_THREAD_STATE, |
| 35 | 32 | makeEnvironmentThreadState, |
| ... | ... | @@ -69,7 +66,7 @@ const BASE_THREAD: OrchestrationThread = { |
| 69 | 66 | |
| 70 | 67 | type TestThreadInput = OrchestrationThreadStreamItem | Error; |
| 71 | 68 | |
| 72 | function testSession(client: WsRpcProtocolClient): RpcSession { | |
| 69 | function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { | |
| 73 | 70 | return { |
| 74 | 71 | client, |
| 75 | 72 | initialConfig: Effect.never, |
| ... | ... | @@ -117,11 +114,11 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 117 | 114 | ), |
| 118 | 115 | ), |
| 119 | 116 | } as unknown as WsRpcProtocolClient; |
| 120 | const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession>>( | |
| 117 | const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | |
| 121 | 118 | Option.some(testSession(client)), |
| 122 | 119 | ); |
| 123 | 120 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none()); |
| 124 | const supervisor = EnvironmentSupervisor.of({ | |
| 121 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 125 | 122 | target: TARGET, |
| 126 | 123 | state: supervisorState, |
| 127 | 124 | session: supervisorSession, |
| ... | ... | @@ -129,8 +126,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 129 | 126 | connect: Effect.void, |
| 130 | 127 | disconnect: Effect.void, |
| 131 | 128 | retryNow: Ref.update(retryCount, (count) => count + 1), |
| 132 | } satisfies EnvironmentSupervisorService); | |
| 133 | const cache = EnvironmentCacheStore.of({ | |
| 129 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | |
| 130 | const cache = Persistence.EnvironmentCacheStore.of({ | |
| 134 | 131 | loadShell: () => Effect.succeed(Option.none()), |
| 135 | 132 | saveShell: () => Effect.void, |
| 136 | 133 | loadThread: (_environmentId, threadId) => |
| ... | ... | @@ -146,8 +143,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 146 | 143 | clear: () => Effect.void, |
| 147 | 144 | }); |
| 148 | 145 | const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( |
| 149 | Effect.provideService(EnvironmentSupervisor, supervisor), | |
| 150 | Effect.provideService(EnvironmentCacheStore, cache), | |
| 146 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 147 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | |
| 151 | 148 | ); |
| 152 | 149 | yield* SubscriptionRef.changes(threadState).pipe( |
| 153 | 150 | Stream.runForEach((state) => |