authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 22:31:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 22:31:45-07:00
log0ad1e9d9e4bf691ad1dcfb5af69de11b0b48047f
treebbb8a32a0b3913f32caba44b02b511340d8a429e
parent2f8d3baa88cd137b11083942a4961ca79a9f99be
signature Signed by PGP key B5690EEEBB952194

[codex] Refactor client-runtime Effect services (#3198)

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