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";
1818function catalogError(operation: string, cause: unknown) {
1919 return new ConnectionTransientError({
2020 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)}`,
2222 });
2323}
2424
apps/mobile/src/connection/platform.ts+53-47
......@@ -10,8 +10,8 @@ import {
1010import {
1111 ConnectionBlockedError,
1212 ConnectionTransientError,
13 ConnectionWakeups,
1413 Connectivity,
14 Wakeups,
1515} from "@t3tools/client-runtime/connection";
1616import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
1717import { AuthStandardClientScopes } from "@t3tools/contracts";
......@@ -41,53 +41,47 @@ function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "on
4141 return "unknown";
4242}
4343
44const connectivityLayer = Layer.succeed(
45 Connectivity,
46 Connectivity.of({
47 status: Effect.tryPromise({
48 try: () => Network.getNetworkStateAsync(),
49 catch: () => undefined,
50 }).pipe(
51 Effect.match({
52 onFailure: () => "unknown" as const,
53 onSuccess: networkStatus,
54 }),
55 ),
56 changes: Stream.callback((queue) =>
44const connectivityLayer = Connectivity.layer({
45 status: Effect.tryPromise({
46 try: () => Network.getNetworkStateAsync(),
47 catch: () => undefined,
48 }).pipe(
49 Effect.match({
50 onFailure: () => "unknown" as const,
51 onSuccess: networkStatus,
52 }),
53 ),
54 changes: Stream.callback((queue) =>
55 Effect.acquireRelease(
56 Effect.sync(() =>
57 Network.addNetworkStateListener((state) => {
58 Queue.offerUnsafe(queue, networkStatus(state));
59 }),
60 ),
61 (subscription) => Effect.sync(() => subscription.remove()),
62 ).pipe(Effect.asVoid),
63 ),
64});
65
66const wakeupsLayer = Wakeups.layer({
67 changes: Stream.merge(
68 Stream.callback<"application-active">((queue) =>
5769 Effect.acquireRelease(
5870 Effect.sync(() =>
59 Network.addNetworkStateListener((state) => {
60 Queue.offerUnsafe(queue, networkStatus(state));
71 AppState.addEventListener("change", (state) => {
72 if (state === "active") {
73 Queue.offerUnsafe(queue, "application-active");
74 }
6175 }),
6276 ),
6377 (subscription) => Effect.sync(() => subscription.remove()),
6478 ).pipe(Effect.asVoid),
6579 ),
66 }),
67);
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 ),
80 managedRelayAccountChanges(appAtomRegistry).pipe(
81 Stream.map(() => "credentials-changed" as const),
8882 ),
89 }),
90);
83 ),
84});
9185
9286const capabilitiesLayer = Layer.succeedContext(
9387 Context.make(
......@@ -98,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext(
9892 if (session === null) {
9993 return yield* new ConnectionBlockedError({
10094 reason: "authentication",
101 message: "Sign in to T3 Cloud to connect this environment.",
95 detail: "Sign in to T3 Cloud to connect this environment.",
10296 });
10397 }
10498 const token = yield* session.readClerkToken().pipe(
......@@ -106,14 +100,14 @@ const capabilitiesLayer = Layer.succeedContext(
106100 (error) =>
107101 new ConnectionTransientError({
108102 reason: "network",
109 message: error.message,
103 detail: error.message,
110104 }),
111105 ),
112106 );
113107 if (token === null) {
114108 return yield* new ConnectionBlockedError({
115109 reason: "authentication",
116 message: "The T3 Cloud session is unavailable.",
110 detail: "The T3 Cloud session is unavailable.",
117111 });
118112 }
119113 return token;
......@@ -132,7 +126,7 @@ const capabilitiesLayer = Layer.succeedContext(
132126 catch: (cause) =>
133127 new ConnectionTransientError({
134128 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)}`,
136130 }),
137131 }).pipe(Effect.map(Option.some)),
138132 }),
......@@ -151,14 +145,14 @@ const capabilitiesLayer = Layer.succeedContext(
151145 Effect.fail(
152146 new ConnectionBlockedError({
153147 reason: "unsupported",
154 message: "SSH environments are only available in the desktop app.",
148 detail: "SSH environments are only available in the desktop app.",
155149 }),
156150 ),
157151 prepare: () =>
158152 Effect.fail(
159153 new ConnectionBlockedError({
160154 reason: "unsupported",
161 message: "SSH environments are only available in the desktop app.",
155 detail: "SSH environments are only available in the desktop app.",
162156 }),
163157 ),
164158 disconnect: () => Effect.void,
......@@ -195,7 +189,19 @@ const environmentOwnedDataCleanupLayer = Layer.succeed(
195189 }),
196190);
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(
199205 connectionStorageLayer,
200206 connectivityLayer,
201207 wakeupsLayer,
apps/mobile/src/connection/runtime.ts+18-6
......@@ -1,16 +1,28 @@
1import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection";
1import { Connection } from "@t3tools/client-runtime/connection";
22import * as Layer from "effect/Layer";
33import { Atom } from "effect/unstable/reactivity";
44
55import { runtimeContextLayer } from "../lib/runtime";
66import { connectionPlatformLayer } from "./platform";
77
8const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
9 Layer.provide(runtimeContextLayer),
10);
8const providedConnectionPlatformLayer: Layer.Layer<
9 Layer.Success<typeof connectionPlatformLayer>,
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(
1322 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
1423);
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 {
88 removeCatalogValue,
99 replaceCatalogValue,
1010} from "@t3tools/client-runtime/platform";
11import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization";
11import { TokenStore } from "@t3tools/client-runtime/authorization";
1212import {
13 ConnectionCredentialStore,
14 ConnectionProfileStore,
1513 ConnectionTransientError,
14 CredentialStore,
15 ProfileStore,
1616} from "@t3tools/client-runtime/connection";
1717import {
1818 EnvironmentId,
......@@ -58,7 +58,7 @@ const LegacyStoredShellSnapshot = Schema.Struct({
5858function catalogError(operation: string, cause: unknown) {
5959 return new ConnectionTransientError({
6060 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)}`,
6262 });
6363}
6464
......@@ -197,7 +197,7 @@ export const connectionStorageLayer = Layer.effectContext(
197197 .update((document) => removeConnectionFromCatalog(document, target))
198198 .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),
199199 });
200 const profileStore = ConnectionProfileStore.of({
200 const profileStore = ProfileStore.make({
201201 get: (connectionId) =>
202202 catalog.read.pipe(
203203 Effect.map((document) =>
......@@ -221,7 +221,7 @@ export const connectionStorageLayer = Layer.effectContext(
221221 ),
222222 })),
223223 });
224 const credentialStore = ConnectionCredentialStore.of({
224 const credentialStore = CredentialStore.make({
225225 get: (connectionId) =>
226226 catalog.read.pipe(
227227 Effect.map((document) =>
......@@ -248,7 +248,7 @@ export const connectionStorageLayer = Layer.effectContext(
248248 ),
249249 })),
250250 });
251 const remoteTokenStore = RemoteDpopAccessTokenStore.of({
251 const remoteTokenStore = TokenStore.make({
252252 get: (environmentId) =>
253253 catalog.read.pipe(
254254 Effect.map((document) =>
......@@ -423,9 +423,9 @@ export const connectionStorageLayer = Layer.effectContext(
423423
424424 return Context.make(ConnectionTargetStore, targetStore).pipe(
425425 Context.add(ConnectionRegistrationStore, registrationStore),
426 Context.add(ConnectionProfileStore, profileStore),
427 Context.add(ConnectionCredentialStore, credentialStore),
428 Context.add(RemoteDpopAccessTokenStore, remoteTokenStore),
426 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
427 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
428 Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),
429429 Context.add(EnvironmentCacheStore, cacheStore),
430430 );
431431 }),
apps/web/src/cloud/linkEnvironment.test.ts+2-4
......@@ -15,9 +15,7 @@ import { HttpClient } from "effect/unstable/http";
1515import { afterEach, beforeEach, vi } from "vite-plus/test";
1616import {
1717 AVAILABLE_CONNECTION_STATE,
18 type EnvironmentRegistryService,
1918 EnvironmentSupervisor,
20 type EnvironmentSupervisorService,
2119 type PreparedConnection,
2220 PrimaryConnectionTarget,
2321} from "@t3tools/client-runtime/connection";
......@@ -114,13 +112,13 @@ function registryLayer(options?: {
114112 connect: Effect.void,
115113 disconnect: Effect.void,
116114 retryNow: Effect.void,
117 } satisfies EnvironmentSupervisorService);
115 } satisfies EnvironmentSupervisor["Service"]);
118116 const registry = {
119117 run: <A, E, R>(_environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) =>
120118 Effect.provideService(effect, EnvironmentSupervisor, supervisor),
121119 runStream: <A, E, R>(_environmentId: EnvironmentId, stream: Stream.Stream<A, E, R>) =>
122120 Stream.provideService(stream, EnvironmentSupervisor, supervisor),
123 } as unknown as EnvironmentRegistryService;
121 } as unknown as EnvironmentRegistry["Service"];
124122 return EnvironmentRegistry.of(registry);
125123 }),
126124 );
apps/web/src/connection/platform.ts+63-56
......@@ -10,11 +10,11 @@ import {
1010import {
1111 ConnectionBlockedError,
1212 ConnectionTransientError,
13 ConnectionWakeups,
1413 Connectivity,
1514 mapRemoteEnvironmentError,
1615 PrimaryConnectionRegistration,
1716 PrimaryConnectionTarget,
17 Wakeups,
1818} from "@t3tools/client-runtime/connection";
1919import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
2020import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
......@@ -50,56 +50,50 @@ function currentNetworkStatus(): "unknown" | "offline" | "online" {
5050 return navigator.onLine ? "online" : "offline";
5151}
5252
53const connectivityLayer = Layer.succeed(
54 Connectivity,
55 Connectivity.of({
56 status: Effect.sync(currentNetworkStatus),
57 changes: Stream.callback((queue) =>
53const connectivityLayer = Connectivity.layer({
54 status: Effect.sync(currentNetworkStatus),
55 changes: Stream.callback((queue) =>
56 Effect.acquireRelease(
57 Effect.sync(() => {
58 const online = () => Queue.offerUnsafe(queue, "online");
59 const offline = () => Queue.offerUnsafe(queue, "offline");
60 window.addEventListener("online", online);
61 window.addEventListener("offline", offline);
62 return { online, offline };
63 }),
64 ({ online, offline }) =>
65 Effect.sync(() => {
66 window.removeEventListener("online", online);
67 window.removeEventListener("offline", offline);
68 }),
69 ).pipe(Effect.asVoid),
70 ),
71});
72
73const wakeupsLayer = Wakeups.layer({
74 changes: Stream.merge(
75 Stream.callback<"application-active">((queue) =>
5876 Effect.acquireRelease(
5977 Effect.sync(() => {
60 const online = () => Queue.offerUnsafe(queue, "online");
61 const offline = () => Queue.offerUnsafe(queue, "offline");
62 window.addEventListener("online", online);
63 window.addEventListener("offline", offline);
64 return { online, offline };
78 const listener = () => {
79 if (document.visibilityState === "visible") {
80 Queue.offerUnsafe(queue, "application-active");
81 }
82 };
83 document.addEventListener("visibilitychange", listener);
84 return listener;
6585 }),
66 ({ online, offline }) =>
86 (listener) =>
6787 Effect.sync(() => {
68 window.removeEventListener("online", online);
69 window.removeEventListener("offline", offline);
88 document.removeEventListener("visibilitychange", listener);
7089 }),
7190 ).pipe(Effect.asVoid),
7291 ),
73 }),
74);
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 ),
92 managedRelayAccountChanges(appAtomRegistry).pipe(
93 Stream.map(() => "credentials-changed" as const),
10094 ),
101 }),
102);
95 ),
96});
10397
10498function clientMetadata() {
10599 const desktop = window.desktopBridge !== undefined;
......@@ -116,12 +110,12 @@ function sshPreparationError(cause: unknown) {
116110 if (message.toLowerCase().includes("cancel")) {
117111 return new ConnectionBlockedError({
118112 reason: "authentication",
119 message,
113 detail: message,
120114 });
121115 }
122116 return new ConnectionTransientError({
123117 reason: "remote-unavailable",
124 message: `Could not prepare the SSH environment: ${message}`,
118 detail: `Could not prepare the SSH environment: ${message}`,
125119 });
126120}
127121
......@@ -139,7 +133,7 @@ export const provisionDesktopSshEnvironment = Effect.fn(
139133 if (pairingToken === null) {
140134 return yield* new ConnectionBlockedError({
141135 reason: "authentication",
142 message: "The SSH environment did not issue a pairing credential.",
136 detail: "The SSH environment did not issue a pairing credential.",
143137 });
144138 }
145139 const descriptor = yield* Effect.tryPromise({
......@@ -170,7 +164,7 @@ const capabilitiesLayer = Layer.effectContext(
170164 if (session === null) {
171165 return yield* new ConnectionBlockedError({
172166 reason: "authentication",
173 message: "Sign in to T3 Cloud to connect this environment.",
167 detail: "Sign in to T3 Cloud to connect this environment.",
174168 });
175169 }
176170 const token = yield* session.readClerkToken().pipe(
......@@ -178,14 +172,14 @@ const capabilitiesLayer = Layer.effectContext(
178172 (error) =>
179173 new ConnectionTransientError({
180174 reason: "network",
181 message: error.message,
175 detail: error.message,
182176 }),
183177 ),
184178 );
185179 if (token === null) {
186180 return yield* new ConnectionBlockedError({
187181 reason: "authentication",
188 message: "The T3 Cloud session is unavailable.",
182 detail: "The T3 Cloud session is unavailable.",
189183 });
190184 }
191185 return token;
......@@ -200,7 +194,7 @@ const capabilitiesLayer = Layer.effectContext(
200194 catch: (cause) =>
201195 new ConnectionTransientError({
202196 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)}`,
204198 }),
205199 }).pipe(Effect.map(Option.fromNullishOr)),
206200 });
......@@ -210,7 +204,7 @@ const capabilitiesLayer = Layer.effectContext(
210204 if (bridge === undefined) {
211205 return yield* new ConnectionBlockedError({
212206 reason: "unsupported",
213 message: "SSH environments are only available in the desktop app.",
207 detail: "SSH environments are only available in the desktop app.",
214208 });
215209 }
216210 return yield* provisionDesktopSshEnvironment(bridge, target);
......@@ -220,7 +214,7 @@ const capabilitiesLayer = Layer.effectContext(
220214 if (bridge === undefined) {
221215 return yield* new ConnectionBlockedError({
222216 reason: "unsupported",
223 message: "SSH environments are only available in the desktop app.",
217 detail: "SSH environments are only available in the desktop app.",
224218 });
225219 }
226220 const bootstrap = yield* Effect.tryPromise({
......@@ -233,7 +227,7 @@ const capabilitiesLayer = Layer.effectContext(
233227 if (bootstrap.pairingToken === null) {
234228 return yield* new ConnectionBlockedError({
235229 reason: "authentication",
236 message: "The SSH environment did not issue a pairing credential.",
230 detail: "The SSH environment did not issue a pairing credential.",
237231 });
238232 }
239233 const access = yield* Effect.tryPromise({
......@@ -256,7 +250,7 @@ const capabilitiesLayer = Layer.effectContext(
256250 catch: (cause) =>
257251 new ConnectionTransientError({
258252 reason: "remote-unavailable",
259 message: `Could not disconnect the SSH environment: ${String(cause)}`,
253 detail: `Could not disconnect the SSH environment: ${String(cause)}`,
260254 }),
261255 });
262256 }),
......@@ -278,7 +272,7 @@ const loadPrimaryConnectionRegistration = Effect.fn(
278272 if (resolved === null) {
279273 return yield* new ConnectionBlockedError({
280274 reason: "configuration",
281 message: "Unable to resolve the primary environment endpoint.",
275 detail: "Unable to resolve the primary environment endpoint.",
282276 });
283277 }
284278 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
......@@ -342,7 +336,20 @@ const rpcRequestObserverLayer = Layer.succeed(
342336 }),
343337);
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(
346353 connectionStorageLayer,
347354 connectivityLayer,
348355 wakeupsLayer,
apps/web/src/connection/runtime.ts+18-6
......@@ -1,16 +1,28 @@
1import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection";
1import { Connection } from "@t3tools/client-runtime/connection";
22import * as Layer from "effect/Layer";
33import { Atom } from "effect/unstable/reactivity";
44
55import { runtimeContextLayer } from "../lib/runtime";
66import { connectionPlatformLayer } from "./platform";
77
8const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
9 Layer.provide(runtimeContextLayer),
10);
8const providedConnectionPlatformLayer: Layer.Layer<
9 Layer.Success<typeof connectionPlatformLayer>,
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(
1322 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
1423);
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", () => {
4343 Effect.gen(function* () {
4444 const failure = new ConnectionTransientError({
4545 reason: "remote-unavailable",
46 message: "permission denied",
46 detail: "permission denied",
4747 });
4848 const store = yield* makeCatalogStore({
4949 read: Effect.fail(failure),
apps/web/src/connection/storage.ts+10-10
......@@ -11,11 +11,11 @@ import {
1111 removeConnectionFromCatalog,
1212 replaceCatalogValue,
1313} from "@t3tools/client-runtime/platform";
14import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization";
14import { TokenStore } from "@t3tools/client-runtime/authorization";
1515import {
16 ConnectionCredentialStore,
17 ConnectionProfileStore,
1816 ConnectionTransientError,
17 CredentialStore,
18 ProfileStore,
1919} from "@t3tools/client-runtime/connection";
2020import {
2121 EnvironmentId,
......@@ -63,7 +63,7 @@ const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson)
6363function catalogError(operation: string, cause: unknown) {
6464 return new ConnectionTransientError({
6565 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)}`,
6767 });
6868}
6969
......@@ -343,7 +343,7 @@ export const connectionStorageLayer = Layer.effectContext(
343343 .update((document) => removeConnectionFromCatalog(document, target))
344344 .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))),
345345 });
346 const profileStore = ConnectionProfileStore.of({
346 const profileStore = ProfileStore.make({
347347 get: (connectionId) =>
348348 catalog.read.pipe(
349349 Effect.map((document) =>
......@@ -367,7 +367,7 @@ export const connectionStorageLayer = Layer.effectContext(
367367 ),
368368 })),
369369 });
370 const credentialStore = ConnectionCredentialStore.of({
370 const credentialStore = CredentialStore.make({
371371 get: (connectionId) =>
372372 catalog.read.pipe(
373373 Effect.map((document) =>
......@@ -394,7 +394,7 @@ export const connectionStorageLayer = Layer.effectContext(
394394 ),
395395 })),
396396 });
397 const remoteTokenStore = RemoteDpopAccessTokenStore.of({
397 const remoteTokenStore = TokenStore.make({
398398 get: (environmentId) =>
399399 catalog.read.pipe(
400400 Effect.map((document) =>
......@@ -527,9 +527,9 @@ export const connectionStorageLayer = Layer.effectContext(
527527
528528 return Context.make(ConnectionTargetStore, targetStore).pipe(
529529 Context.add(ConnectionRegistrationStore, registrationStore),
530 Context.add(ConnectionProfileStore, profileStore),
531 Context.add(ConnectionCredentialStore, credentialStore),
532 Context.add(RemoteDpopAccessTokenStore, remoteTokenStore),
530 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
531 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
532 Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),
533533 Context.add(EnvironmentCacheStore, cacheStore),
534534 );
535535 }),
packages/client-runtime/src/authorization/index.ts+6-3
......@@ -1,4 +1,7 @@
1export * from "./layer.ts";
21export * from "./remote.ts";
3export * from "./service.ts";
4export * from "./tokenStore.ts";
2export {
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";
55import * as Option from "effect/Option";
66import * as Ref from "effect/Ref";
77
8import { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts";
8import * as ManagedRelay from "../relay/managedRelay.ts";
99import { remoteHttpClientLayer } from "../rpc/http.ts";
10import { ClientPresentation } from "../platform/capabilities.ts";
11import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts";
12import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts";
13import { remoteEnvironmentAuthorizationLayer } from "./layer.ts";
10import * as ClientCapabilities from "../platform/capabilities.ts";
11import * as RemoteEnvironmentAuthorization from "./service.ts";
12import * as TokenStore from "./tokenStore.ts";
1413
1514const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
1615const ENDPOINT = {
......@@ -30,7 +29,7 @@ const DESCRIPTOR = {
3029 repositoryIdentity: true,
3130 },
3231};
33const BOOTSTRAP: RelayEnvironmentAuthorization = {
32const BOOTSTRAP: RemoteEnvironmentAuthorization.RelayEnvironmentAuthorization = {
3433 environmentId: ENVIRONMENT_ID,
3534 endpoint: ENDPOINT,
3635 credential: "relay-bootstrap",
......@@ -76,7 +75,7 @@ const authInvalid = () =>
7675 );
7776
7877const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: {
79 readonly initialToken?: RemoteDpopAccessToken;
78 readonly initialToken?: TokenStore.RemoteDpopAccessToken;
8079 readonly responses: ReadonlyArray<Response>;
8180}) {
8281 const tokens = yield* Ref.make(
......@@ -96,7 +95,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
9695 >([]);
9796 const fetch = recordedFetch(input.responses);
9897
99 const tokenStore = RemoteDpopAccessTokenStore.of({
98 const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({
10099 get: (environmentId) =>
101100 Ref.get(tokens).pipe(
102101 Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))),
......@@ -114,23 +113,23 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
114113 return next;
115114 }),
116115 });
117 const signer = ManagedRelayDpopSigner.of({
116 const signer = ManagedRelay.ManagedRelayDpopSigner.of({
118117 thumbprint: Effect.succeed("thumbprint-1"),
119118 createProof: (proofInput) =>
120119 Ref.update(proofInputs, (current) => [...current, proofInput]).pipe(
121120 Effect.as(`proof:${proofInput.url}`),
122 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
121 Effect.mapError((cause) => new ManagedRelay.ManagedRelayDpopSignerError({ cause })),
123122 ),
124123 });
125 const layer = remoteEnvironmentAuthorizationLayer.pipe(
124 const layer = RemoteEnvironmentAuthorization.layer.pipe(
126125 Layer.provide(
127126 Layer.mergeAll(
128127 remoteHttpClientLayer(fetch.fetchFn),
129 Layer.succeed(ManagedRelayDpopSigner, signer),
130 Layer.succeed(RemoteDpopAccessTokenStore, tokenStore),
128 Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, signer),
129 Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore),
131130 Layer.succeed(
132 ClientPresentation,
133 ClientPresentation.of({
131 ClientCapabilities.ClientPresentation,
132 ClientCapabilities.ClientPresentation.of({
134133 metadata: {
135134 label: "T3 Code Test",
136135 deviceType: "mobile",
......@@ -159,7 +158,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
159158describe("RemoteEnvironmentAuthorization", () => {
160159 it.effect("reuses a valid persisted environment token without contacting the relay", () =>
161160 Effect.gen(function* () {
162 const cached = new RemoteDpopAccessToken({
161 const cached = new TokenStore.RemoteDpopAccessToken({
163162 environmentId: ENVIRONMENT_ID,
164163 label: DESCRIPTOR.label,
165164 endpoint: ENDPOINT,
......@@ -173,7 +172,7 @@ describe("RemoteEnvironmentAuthorization", () => {
173172 });
174173
175174 const authorized = yield* Effect.gen(function* () {
176 const remote = yield* RemoteEnvironmentAuthorization;
175 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
177176 return yield* remote.authorizeDpop({
178177 expectedEnvironmentId: ENVIRONMENT_ID,
179178 obtainBootstrap: harness.obtainBootstrap,
......@@ -191,7 +190,7 @@ describe("RemoteEnvironmentAuthorization", () => {
191190
192191 it.effect("refreshes and persists an expired environment token", () =>
193192 Effect.gen(function* () {
194 const expired = new RemoteDpopAccessToken({
193 const expired = new TokenStore.RemoteDpopAccessToken({
195194 environmentId: ENVIRONMENT_ID,
196195 label: DESCRIPTOR.label,
197196 endpoint: ENDPOINT,
......@@ -209,7 +208,7 @@ describe("RemoteEnvironmentAuthorization", () => {
209208 });
210209
211210 const authorized = yield* Effect.gen(function* () {
212 const remote = yield* RemoteEnvironmentAuthorization;
211 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
213212 return yield* remote.authorizeDpop({
214213 expectedEnvironmentId: ENVIRONMENT_ID,
215214 obtainBootstrap: harness.obtainBootstrap,
......@@ -230,7 +229,7 @@ describe("RemoteEnvironmentAuthorization", () => {
230229
231230 it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () =>
232231 Effect.gen(function* () {
233 const cached = new RemoteDpopAccessToken({
232 const cached = new TokenStore.RemoteDpopAccessToken({
234233 environmentId: ENVIRONMENT_ID,
235234 label: DESCRIPTOR.label,
236235 endpoint: ENDPOINT,
......@@ -249,7 +248,7 @@ describe("RemoteEnvironmentAuthorization", () => {
249248 });
250249
251250 const authorized = yield* Effect.gen(function* () {
252 const remote = yield* RemoteEnvironmentAuthorization;
251 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
253252 return yield* remote.authorizeDpop({
254253 expectedEnvironmentId: ENVIRONMENT_ID,
255254 obtainBootstrap: harness.obtainBootstrap,
......@@ -269,7 +268,7 @@ describe("RemoteEnvironmentAuthorization", () => {
269268
270269 it.effect("refreshes a cached endpoint after consecutive transient failures", () =>
271270 Effect.gen(function* () {
272 const cached = new RemoteDpopAccessToken({
271 const cached = new TokenStore.RemoteDpopAccessToken({
273272 environmentId: ENVIRONMENT_ID,
274273 label: DESCRIPTOR.label,
275274 endpoint: ENDPOINT,
......@@ -289,7 +288,7 @@ describe("RemoteEnvironmentAuthorization", () => {
289288 });
290289
291290 const authorized = yield* Effect.gen(function* () {
292 const remote = yield* RemoteEnvironmentAuthorization;
291 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
293292 const firstFailure = yield* remote
294293 .authorizeDpop({
295294 expectedEnvironmentId: ENVIRONMENT_ID,
......@@ -329,7 +328,7 @@ describe("RemoteEnvironmentAuthorization", () => {
329328 });
330329
331330 yield* Effect.gen(function* () {
332 const remote = yield* RemoteEnvironmentAuthorization;
331 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
333332 return yield* remote.authorizeDpop({
334333 expectedEnvironmentId: ENVIRONMENT_ID,
335334 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 @@
11import { EnvironmentId } from "@t3tools/contracts";
22import 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";
317import * 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
827export interface RelayEnvironmentAuthorization {
928 readonly environmentId: EnvironmentId;
......@@ -37,3 +56,247 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
3756 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
3857 }
3958>()("@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";
22import { RelayManagedEndpoint } from "@t3tools/contracts/relay";
33import * as Context from "effect/Context";
44import type * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
56import type * as Option from "effect/Option";
67import * as Schema from "effect/Schema";
78
......@@ -28,3 +29,9 @@ export class RemoteDpopAccessTokenStore extends Context.Service<
2829 readonly remove: (environmentId: EnvironmentId) => Effect.Effect<void, ConnectionAttemptError>;
2930 }
3031>()("@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 @@
11import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
42import * as Option from "effect/Option";
53import * as Schema from "effect/Schema";
64
7import type { ConnectionAttemptError } from "./model.ts";
85import {
96 BearerConnectionTarget,
107 PrimaryConnectionTarget,
......@@ -116,28 +113,3 @@ export function connectionRegistrationCatalogEntry(
116113 };
117114 }
118115}
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 @@
11import * as Context from "effect/Context";
22import type * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
34import type * as Stream from "effect/Stream";
45
56import type { NetworkStatus } from "./model.ts";
......@@ -11,3 +12,8 @@ export class Connectivity extends Context.Service<
1112 readonly changes: Stream.Stream<NetworkStatus>;
1213 }
1314>()("@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 {
99 ConnectionAttemptStage,
1010 PreparedConnection,
1111} from "./model.ts";
12import { ConnectionResolver } from "./resolver.ts";
13import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts";
12import * as ConnectionResolver from "./resolver.ts";
13import * as RpcSession from "../rpc/session.ts";
1414
1515export type ConnectionDriverProgress =
1616 | {
......@@ -23,44 +23,42 @@ export type ConnectionDriverProgress =
2323
2424export interface EnvironmentConnectionLease {
2525 readonly prepared: PreparedConnection;
26 readonly session: RpcSession;
26 readonly session: RpcSession.RpcSession;
2727}
2828
29export interface ConnectionDriverService {
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(
29export class ConnectionDriver extends Context.Service<
4130 ConnectionDriver,
42 Effect.gen(function* () {
43 const resolver = yield* ConnectionResolver;
44 const sessions = yield* RpcSessionFactory;
45
46 const connect = Effect.fn("ConnectionDriver.connect")(function* (
31 {
32 readonly connect: (
4733 entry: ConnectionCatalogEntry,
4834 reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>,
49 ) {
50 const target = entry.target;
51 yield* Effect.annotateCurrentSpan({
52 "connection.environment.id": target.environmentId,
53 "connection.target.kind": target._tag,
54 });
55 yield* reportProgress({ stage: "preparing" });
56 const prepared = yield* resolver.prepare(entry);
57 yield* reportProgress({ stage: "opening", prepared });
58 const session = yield* sessions.connect(prepared);
59 yield* reportProgress({ stage: "synchronizing", prepared });
60 yield* session.ready;
61 return { prepared, session } satisfies EnvironmentConnectionLease;
35 ) => Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>;
36 }
37>()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {}
38
39export const make = Effect.gen(function* () {
40 const resolver = yield* ConnectionResolver.ConnectionResolver;
41 const sessions = yield* RpcSession.RpcSessionFactory;
42
43 const connect = Effect.fn("ConnectionDriver.connect")(function* (
44 entry: ConnectionCatalogEntry,
45 reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>,
46 ) {
47 const target = entry.target;
48 yield* Effect.annotateCurrentSpan({
49 "connection.environment.id": target.environmentId,
50 "connection.target.kind": target._tag,
6251 });
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 });
65 }),
66);
64export const layer = Layer.effect(ConnectionDriver, make);
packages/client-runtime/src/connection/errors.ts+17-17
......@@ -11,14 +11,14 @@ import {
1111export function profileMissingError(connectionId: string): ConnectionBlockedError {
1212 return new ConnectionBlockedError({
1313 reason: "configuration",
14 message: `Connection profile ${connectionId} is unavailable.`,
14 detail: `Connection profile ${connectionId} is unavailable.`,
1515 });
1616}
1717
1818export function credentialMissingError(connectionId: string): ConnectionBlockedError {
1919 return new ConnectionBlockedError({
2020 reason: "authentication",
21 message: `Connection credential ${connectionId} is unavailable.`,
21 detail: `Connection credential ${connectionId} is unavailable.`,
2222 });
2323}
2424
......@@ -28,7 +28,7 @@ export function environmentMismatchError(input: {
2828}): ConnectionBlockedError {
2929 return new ConnectionBlockedError({
3030 reason: "configuration",
31 message: `Connected environment ${input.actual} does not match ${input.expected}.`,
31 detail: `Connected environment ${input.actual} does not match ${input.expected}.`,
3232 });
3333}
3434
......@@ -40,34 +40,34 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
4040 case "RelayAgentActivityPublishProofInvalidError":
4141 return new ConnectionBlockedError({
4242 reason: "authentication",
43 message: error.message,
43 detail: error.message,
4444 traceId: error.traceId,
4545 });
4646 case "RelayEnvironmentConnectNotAuthorizedError":
4747 case "RelayEnvironmentLinkProofInvalidError":
4848 return new ConnectionBlockedError({
4949 reason: "permission",
50 message: error.message,
50 detail: error.message,
5151 traceId: error.traceId,
5252 });
5353 case "RelayEnvironmentEndpointTimedOutError":
5454 return new ConnectionTransientError({
5555 reason: "timeout",
56 message: error.message,
56 detail: error.message,
5757 traceId: error.traceId,
5858 });
5959 case "RelayEnvironmentEndpointUnavailableError":
6060 case "RelayEnvironmentLinkUnavailableError":
6161 return new ConnectionTransientError({
6262 reason: "endpoint-unavailable",
63 message: error.message,
63 detail: error.message,
6464 traceId: error.traceId,
6565 });
6666 case "RelayEnvironmentLinkFailedError":
6767 case "RelayInternalError":
6868 return new ConnectionTransientError({
6969 reason: "relay-unavailable",
70 message: error.message,
70 detail: error.message,
7171 traceId: error.traceId,
7272 });
7373 }
......@@ -80,13 +80,13 @@ export function mapManagedRelayError(error: ManagedRelayClientError): Connection
8080 if (error.cause?._tag === "ManagedRelayRequestTimeoutError") {
8181 return new ConnectionTransientError({
8282 reason: "timeout",
83 message: error.message,
83 detail: error.message,
8484 ...(error.traceId ? { traceId: error.traceId } : {}),
8585 });
8686 }
8787 return new ConnectionTransientError({
8888 reason: "relay-unavailable",
89 message: error.message,
89 detail: error.message,
9090 ...(error.traceId ? { traceId: error.traceId } : {}),
9191 });
9292}
......@@ -98,43 +98,43 @@ export function mapRemoteEnvironmentError(
9898 case "EnvironmentAuthInvalidError":
9999 return new ConnectionBlockedError({
100100 reason: "authentication",
101 message: "The environment credential is invalid.",
101 detail: "The environment credential is invalid.",
102102 traceId: error.traceId,
103103 });
104104 case "EnvironmentScopeRequiredError":
105105 case "EnvironmentOperationForbiddenError":
106106 return new ConnectionBlockedError({
107107 reason: "permission",
108 message: "The environment credential does not grant the required access.",
108 detail: "The environment credential does not grant the required access.",
109109 traceId: error.traceId,
110110 });
111111 case "EnvironmentRequestInvalidError":
112112 return new ConnectionBlockedError({
113113 reason: "configuration",
114 message: "The environment rejected the authentication request.",
114 detail: "The environment rejected the authentication request.",
115115 traceId: error.traceId,
116116 });
117117 case "RemoteEnvironmentAuthTimeoutError":
118118 return new ConnectionTransientError({
119119 reason: "timeout",
120 message: error.message,
120 detail: error.message,
121121 });
122122 case "RemoteEnvironmentAuthFetchError":
123123 return new ConnectionTransientError({
124124 reason: "network",
125 message: error.message,
125 detail: error.message,
126126 });
127127 case "EnvironmentInternalError":
128128 return new ConnectionTransientError({
129129 reason: "remote-unavailable",
130 message: "The environment could not authorize the connection.",
130 detail: "The environment could not authorize the connection.",
131131 traceId: error.traceId,
132132 });
133133 case "RemoteEnvironmentAuthInvalidJsonError":
134134 case "RemoteEnvironmentAuthUndeclaredStatusError":
135135 return new ConnectionTransientError({
136136 reason: "remote-unavailable",
137 message: error.message,
137 detail: error.message,
138138 });
139139 }
140140}
packages/client-runtime/src/connection/index.ts+29-8
......@@ -1,12 +1,33 @@
11export * from "./catalog.ts";
2export * from "./connectivity.ts";
3export * from "./driver.ts";
2export * as Connectivity from "./connectivity.ts";
3export * as CredentialStore from "./credentialStore.ts";
4export {
5 ConnectionDriver,
6 type ConnectionDriverProgress,
7 type EnvironmentConnectionLease,
8} from "./driver.ts";
49export * from "./errors.ts";
5export * from "./layer.ts";
10export * as Connection from "./layer.ts";
611export * 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";
824export * from "./presentation.ts";
9export * from "./registry.ts";
10export * from "./resolver.ts";
11export * from "./supervisor.ts";
12export * from "./wakeups.ts";
25export * as ProfileStore from "./profileStore.ts";
26export {
27 EnvironmentNotRegisteredError,
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";
22import * as Layer from "effect/Layer";
33import * as Stream from "effect/Stream";
44
5import { connectionResolverLayer } from "./resolver.ts";
6import { connectionDriverLayer } from "./driver.ts";
7import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts";
8import { connectionOnboardingLayer } from "./onboarding.ts";
9import { PlatformConnectionSource } from "../platform/source.ts";
10import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts";
11import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts";
12import { rpcSessionFactoryLayer } from "../rpc/session.ts";
13
14const resolverLayer = connectionResolverLayer.pipe(
15 Layer.provide(remoteEnvironmentAuthorizationLayer),
5import * as ConnectionResolver from "./resolver.ts";
6import * as ConnectionDriver from "./driver.ts";
7import * as EnvironmentRegistry from "./registry.ts";
8import * as ConnectionOnboarding from "./onboarding.ts";
9import * as PlatformConnectionSource from "../platform/source.ts";
10import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
11import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
12import * as RpcSession from "../rpc/session.ts";
13
14const resolverLayer = ConnectionResolver.layer.pipe(
15 Layer.provide(RemoteEnvironmentAuthorization.layer),
1616);
1717
18const driverLayer = connectionDriverLayer.pipe(
19 Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)),
18const driverLayer = ConnectionDriver.layer.pipe(
19 Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)),
2020);
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
2626const connectionServicesLayer = Layer.mergeAll(
2727 registryLayer,
28 relayEnvironmentDiscoveryLayer,
28 RelayEnvironmentDiscovery.layer,
2929 onboardingLayer,
3030);
3131
3232const connectionStartupLayer = Layer.effectDiscard(
3333 Effect.gen(function* () {
34 const registry = yield* EnvironmentRegistry;
35 const platformSource = yield* PlatformConnectionSource;
34 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
35 const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource;
3636 yield* registry.start;
3737 yield* platformSource.registrations.pipe(
3838 Stream.runForEach(registry.registerPlatform),
......@@ -41,6 +41,4 @@ const connectionStartupLayer = Layer.effectDiscard(
4141 }).pipe(Effect.withSpan("clientRuntime.connection.application.start")),
4242);
4343
44export const connectionLayer = connectionStartupLayer.pipe(
45 Layer.provideMerge(connectionServicesLayer),
46);
44export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer));
packages/client-runtime/src/connection/model.ts+31-26
......@@ -57,44 +57,49 @@ export type ConnectionTargetKind = ConnectionTarget["_tag"];
5757
5858export type NetworkStatus = "unknown" | "offline" | "online";
5959
60export type ConnectionTransientReason =
61 | "network"
62 | "timeout"
63 | "transport"
64 | "endpoint-unavailable"
65 | "relay-unavailable"
66 | "remote-unavailable";
67
68export type ConnectionBlockedReason =
69 | "authentication"
70 | "configuration"
71 | "permission"
72 | "unsupported";
60export const ConnectionTransientReason = Schema.Literals([
61 "network",
62 "timeout",
63 "transport",
64 "endpoint-unavailable",
65 "relay-unavailable",
66 "remote-unavailable",
67]);
68export type ConnectionTransientReason = typeof ConnectionTransientReason.Type;
69
70export const ConnectionBlockedReason = Schema.Literals([
71 "authentication",
72 "configuration",
73 "permission",
74 "unsupported",
75]);
76export type ConnectionBlockedReason = typeof ConnectionBlockedReason.Type;
7377
7478export class ConnectionTransientError extends Schema.TaggedErrorClass<ConnectionTransientError>()(
7579 "ConnectionTransientError",
7680 {
77 reason: Schema.Literals([
78 "network",
79 "timeout",
80 "transport",
81 "endpoint-unavailable",
82 "relay-unavailable",
83 "remote-unavailable",
84 ]),
85 message: Schema.String,
81 reason: ConnectionTransientReason,
82 detail: Schema.String,
8683 traceId: Schema.optionalKey(Schema.String),
8784 },
88) {}
85) {
86 override get message(): string {
87 return this.detail;
88 }
89}
8990
9091export class ConnectionBlockedError extends Schema.TaggedErrorClass<ConnectionBlockedError>()(
9192 "ConnectionBlockedError",
9293 {
93 reason: Schema.Literals(["authentication", "configuration", "permission", "unsupported"]),
94 message: Schema.String,
94 reason: ConnectionBlockedReason,
95 detail: Schema.String,
9596 traceId: Schema.optionalKey(Schema.String),
9697 },
97) {}
98) {
99 override get message(): string {
100 return this.detail;
101 }
102}
98103
99104export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError;
100105
packages/client-runtime/src/connection/onboarding.ts+52-47
......@@ -6,22 +6,22 @@ import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import * as Schema from "effect/Schema";
88import * as SubscriptionRef from "effect/SubscriptionRef";
9import { HttpClient } from "effect/unstable/http";
9import * as HttpClient from "effect/unstable/http/HttpClient";
1010
1111import { bootstrapRemoteBearerSession } from "../authorization/remote.ts";
1212import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts";
1313import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
14import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts";
14import * as ClientCapabilities from "../platform/capabilities.ts";
1515import {
1616 BearerConnectionCredential,
1717 BearerConnectionProfile,
1818 BearerConnectionRegistration,
1919 type ConnectionCatalogEntry,
2020 type ConnectionCredential,
21 ConnectionCredentialStore,
2221 SshConnectionProfile,
2322 SshConnectionRegistration,
2423} from "./catalog.ts";
24import * as ConnectionCredentialStore from "./credentialStore.ts";
2525import { mapRemoteEnvironmentError } from "./errors.ts";
2626import {
2727 BearerConnectionTarget,
......@@ -29,8 +29,8 @@ import {
2929 SshConnectionTarget,
3030 type ConnectionAttemptError,
3131} from "./model.ts";
32import type { ConnectionPersistenceError } from "../platform/persistence.ts";
33import { EnvironmentRegistry } from "./registry.ts";
32import * as Persistence from "../platform/persistence.ts";
33import * as EnvironmentRegistry from "./registry.ts";
3434
3535export interface PairingConnectionInput {
3636 readonly pairingUrl?: string;
......@@ -54,13 +54,19 @@ export class ConnectionOnboarding extends Context.Service<
5454 {
5555 readonly registerPairing: (
5656 input: PairingConnectionInput,
57 ) => Effect.Effect<EnvironmentId, ConnectionAttemptError | ConnectionPersistenceError>;
57 ) => Effect.Effect<
58 EnvironmentId,
59 ConnectionAttemptError | Persistence.ConnectionPersistenceError
60 >;
5861 readonly registerSsh: (
5962 input: SshConnectionInput,
60 ) => Effect.Effect<EnvironmentId, ConnectionAttemptError | ConnectionPersistenceError>;
63 ) => Effect.Effect<
64 EnvironmentId,
65 ConnectionAttemptError | Persistence.ConnectionPersistenceError
66 >;
6167 readonly updateBearer: (
6268 input: BearerConnectionUpdateInput,
63 ) => Effect.Effect<void, ConnectionAttemptError | ConnectionPersistenceError>;
69 ) => Effect.Effect<void, ConnectionAttemptError | Persistence.ConnectionPersistenceError>;
6470 }
6571>()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {}
6672
......@@ -71,7 +77,7 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso
7177 catch: (cause) =>
7278 new ConnectionBlockedError({
7379 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.",
7581 }),
7682 });
7783 },
......@@ -81,7 +87,7 @@ export const preparePairingRegistration = Effect.fn(
8187 "clientRuntime.connection.onboarding.preparePairingRegistration",
8288)(function* (input: PairingConnectionInput) {
8389 const target = yield* resolvePairingTarget(input);
84 const presentation = yield* ClientPresentation;
90 const presentation = yield* ClientCapabilities.ClientPresentation;
8591 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
8692 httpBaseUrl: target.httpBaseUrl,
8793 }).pipe(Effect.mapError(mapRemoteEnvironmentError));
......@@ -116,7 +122,7 @@ export const registerPairingConnection = Effect.fn(
116122 "clientRuntime.connection.onboarding.registerPairingConnection",
117123)(function* (input: PairingConnectionInput) {
118124 const registration = yield* preparePairingRegistration(input);
119 const registry = yield* EnvironmentRegistry;
125 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
120126 yield* registry.register(registration);
121127 return registration.target.environmentId;
122128});
......@@ -127,8 +133,8 @@ const isBearerProfile = Schema.is(BearerConnectionProfile);
127133export const updateBearerConnection = Effect.fn(
128134 "clientRuntime.connection.onboarding.updateBearerConnection",
129135)(function* (input: BearerConnectionUpdateInput) {
130 const registry = yield* EnvironmentRegistry;
131 const credentials = yield* ConnectionCredentialStore;
136 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
137 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
132138 const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId);
133139 const credential =
134140 entry?.target._tag === "BearerConnectionTarget"
......@@ -159,7 +165,7 @@ export const prepareBearerConnectionUpdate = Effect.fn(
159165 ) {
160166 return yield* new ConnectionBlockedError({
161167 reason: "configuration",
162 message: "Only saved bearer environments can be edited.",
168 detail: "Only saved bearer environments can be edited.",
163169 });
164170 }
165171
......@@ -167,7 +173,7 @@ export const prepareBearerConnectionUpdate = Effect.fn(
167173 if (Option.isNone(credential) || !isBearerCredential(credential.value)) {
168174 return yield* new ConnectionBlockedError({
169175 reason: "authentication",
170 message: "The saved bearer credential is unavailable.",
176 detail: "The saved bearer credential is unavailable.",
171177 });
172178 }
173179
......@@ -175,7 +181,7 @@ export const prepareBearerConnectionUpdate = Effect.fn(
175181 if (label === "") {
176182 return yield* new ConnectionBlockedError({
177183 reason: "configuration",
178 message: "Environment label cannot be empty.",
184 detail: "Environment label cannot be empty.",
179185 });
180186 }
181187 const httpBaseUrl = yield* Effect.try({
......@@ -183,7 +189,7 @@ export const prepareBearerConnectionUpdate = Effect.fn(
183189 catch: (cause) =>
184190 new ConnectionBlockedError({
185191 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.",
187193 }),
188194 });
189195 const connectionId = entry.target.connectionId;
......@@ -207,7 +213,7 @@ export const prepareBearerConnectionUpdate = Effect.fn(
207213export const prepareSshRegistration = Effect.fn(
208214 "clientRuntime.connection.onboarding.prepareSshRegistration",
209215)(function* (input: SshConnectionInput) {
210 const gateway = yield* SshEnvironmentGateway;
216 const gateway = yield* ClientCapabilities.SshEnvironmentGateway;
211217 const provisioned = yield* gateway.provision(input.target);
212218 const connectionId = `ssh:${provisioned.environmentId}`;
213219 const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias;
......@@ -231,37 +237,36 @@ export const registerSshConnection = Effect.fn(
231237 "clientRuntime.connection.onboarding.registerSshConnection",
232238)(function* (input: SshConnectionInput) {
233239 const registration = yield* prepareSshRegistration(input);
234 const registry = yield* EnvironmentRegistry;
240 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
235241 yield* registry.register(registration);
236242 return registration.target.environmentId;
237243});
238244
239export const connectionOnboardingLayer = Layer.effect(
240 ConnectionOnboarding,
241 Effect.gen(function* () {
242 const registry = yield* EnvironmentRegistry;
243 const presentation = yield* ClientPresentation;
244 const httpClient = yield* HttpClient.HttpClient;
245 const ssh = yield* SshEnvironmentGateway;
246 const credentials = yield* ConnectionCredentialStore;
245export const make = Effect.gen(function* () {
246 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
247 const presentation = yield* ClientCapabilities.ClientPresentation;
248 const httpClient = yield* HttpClient.HttpClient;
249 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
250 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
247251
248 return ConnectionOnboarding.of({
249 registerPairing: (input) =>
250 registerPairingConnection(input).pipe(
251 Effect.provideService(EnvironmentRegistry, registry),
252 Effect.provideService(ClientPresentation, presentation),
253 Effect.provideService(HttpClient.HttpClient, httpClient),
254 ),
255 registerSsh: (input) =>
256 registerSshConnection(input).pipe(
257 Effect.provideService(EnvironmentRegistry, registry),
258 Effect.provideService(SshEnvironmentGateway, ssh),
259 ),
260 updateBearer: (input) =>
261 updateBearerConnection(input).pipe(
262 Effect.provideService(EnvironmentRegistry, registry),
263 Effect.provideService(ConnectionCredentialStore, credentials),
264 ),
265 });
266 }),
267);
252 return ConnectionOnboarding.of({
253 registerPairing: (input) =>
254 registerPairingConnection(input).pipe(
255 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry),
256 Effect.provideService(ClientCapabilities.ClientPresentation, presentation),
257 Effect.provideService(HttpClient.HttpClient, httpClient),
258 ),
259 registerSsh: (input) =>
260 registerSshConnection(input).pipe(
261 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry),
262 Effect.provideService(ClientCapabilities.SshEnvironmentGateway, ssh),
263 ),
264 updateBearer: (input) =>
265 updateBearerConnection(input).pipe(
266 Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry),
267 Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials),
268 ),
269 });
270});
271
272export const layer = Layer.effect(ConnectionOnboarding, make);
packages/client-runtime/src/connection/presentation.test.ts+3-3
......@@ -67,7 +67,7 @@ describe("connection presentation", () => {
6767 attempt: 2,
6868 lastFailure: new ConnectionTransientError({
6969 reason: "transport",
70 message: "Socket closed.",
70 detail: "Socket closed.",
7171 traceId: "trace-previous",
7272 }),
7373 }),
......@@ -85,7 +85,7 @@ describe("connection presentation", () => {
8585 retryAt: 1,
8686 lastFailure: new ConnectionTransientError({
8787 reason: "transport",
88 message: "Disconnected.",
88 detail: "Disconnected.",
8989 traceId: "trace-1",
9090 }),
9191 }),
......@@ -106,7 +106,7 @@ describe("connection presentation", () => {
106106 attempt: 2,
107107 lastFailure: new ConnectionTransientError({
108108 reason: "transport",
109 message: "Relay connection timed out.",
109 detail: "Relay connection timed out.",
110110 traceId: "trace-retry",
111111 }),
112112 }),
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";
1414import * as Stream from "effect/Stream";
1515import * as SubscriptionRef from "effect/SubscriptionRef";
1616
17import { SshEnvironmentGateway } from "../platform/capabilities.ts";
18import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts";
17import * as ClientCapabilities from "../platform/capabilities.ts";
18import * as TokenStore from "../authorization/tokenStore.ts";
1919import {
2020 BearerConnectionCredential,
2121 BearerConnectionProfile,
2222 BearerConnectionRegistration,
2323 type ConnectionRegistration,
24 ConnectionCredentialStore,
25 ConnectionProfileStore,
2624 PrimaryConnectionRegistration,
2725 RelayConnectionRegistration,
2826 SshConnectionProfile,
2927 type ConnectionCredential,
3028 type ConnectionProfile,
3129} from "./catalog.ts";
32import { Connectivity } from "./connectivity.ts";
33import { ConnectionDriver } from "./driver.ts";
30import * as Connectivity from "./connectivity.ts";
31import * as ConnectionCredentialStore from "./credentialStore.ts";
32import * as ConnectionDriver from "./driver.ts";
3433import {
3534 ConnectionTransientError,
3635 BearerConnectionTarget,
......@@ -41,17 +40,12 @@ import {
4140 type PreparedConnection,
4241 type SupervisorConnectionState,
4342} from "./model.ts";
44import {
45 ConnectionPersistenceError,
46 ConnectionRegistrationStore,
47 ConnectionTargetStore,
48 EnvironmentCacheStore,
49 EnvironmentOwnedDataCleanup,
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";
43import * as Persistence from "../platform/persistence.ts";
44import * as ConnectionProfileStore from "./profileStore.ts";
45import * as EnvironmentRegistry from "./registry.ts";
46import * as RpcSession from "../rpc/session.ts";
47import * as EnvironmentSupervisor from "./supervisor.ts";
48import * as ConnectionWakeups from "./wakeups.ts";
5549
5650const TARGET = new PrimaryConnectionTarget({
5751 environmentId: EnvironmentId.make("environment-1"),
......@@ -137,10 +131,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
137131 readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect<void>;
138132 readonly beforeRegistrationRegister?: (
139133 registration: ConnectionRegistration,
140 ) => Effect.Effect<void, ConnectionPersistenceError>;
134 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
141135 readonly beforeRegistrationRemove?: (
142136 target: ConnectionTarget,
143 ) => Effect.Effect<void, ConnectionPersistenceError>;
137 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
144138 },
145139) {
146140 const storedTargets = yield* Ref.make(
......@@ -160,7 +154,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
160154 new Map([
161155 [
162156 SSH_CONNECTION.environmentId,
163 new RemoteDpopAccessToken({
157 new TokenStore.RemoteDpopAccessToken({
164158 environmentId: SSH_CONNECTION.environmentId,
165159 label: SSH_CONNECTION.label,
166160 endpoint: {
......@@ -177,10 +171,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
177171 );
178172 const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]);
179173
180 const targetStore = ConnectionTargetStore.of({
174 const targetStore = Persistence.ConnectionTargetStore.of({
181175 list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])),
182176 });
183 const registrationStore = ConnectionRegistrationStore.of({
177 const registrationStore = Persistence.ConnectionRegistrationStore.of({
184178 register: (registration) =>
185179 Effect.gen(function* () {
186180 yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void;
......@@ -239,7 +233,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
239233 });
240234 }),
241235 });
242 const cacheStore = EnvironmentCacheStore.of({
236 const cacheStore = Persistence.EnvironmentCacheStore.of({
243237 loadShell: (environmentId) =>
244238 Ref.get(shellCache).pipe(
245239 Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))),
......@@ -264,16 +258,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
264258 ),
265259 ),
266260 });
267 const ownedDataCleanup = EnvironmentOwnedDataCleanup.of({
261 const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({
268262 clear: (environmentId) =>
269263 Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]),
270264 });
271265 const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online");
272 const connectivity = Connectivity.of({
266 const connectivity = Connectivity.Connectivity.of({
273267 status: SubscriptionRef.get(networkStatus),
274268 changes: SubscriptionRef.changes(networkStatus),
275269 });
276 const profileStore = ConnectionProfileStore.of({
270 const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({
277271 get: (connectionId) =>
278272 Ref.update(profileReadCount, (count) => count + 1).pipe(
279273 Effect.andThen(Ref.get(storedProfiles)),
......@@ -292,7 +286,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
292286 return next;
293287 }),
294288 });
295 const credentialStore = ConnectionCredentialStore.of({
289 const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({
296290 get: (connectionId) =>
297291 Ref.get(storedCredentials).pipe(
298292 Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))),
......@@ -310,7 +304,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
310304 return next;
311305 }),
312306 });
313 const tokenStore = RemoteDpopAccessTokenStore.of({
307 const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({
314308 get: (environmentId) =>
315309 Ref.get(storedRemoteTokens).pipe(
316310 Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))),
......@@ -328,12 +322,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
328322 return next;
329323 }),
330324 });
331 const sshGateway = SshEnvironmentGateway.of({
325 const sshGateway = ClientCapabilities.SshEnvironmentGateway.of({
332326 provision: () => Effect.die(new Error("SSH provisioning is not used.")),
333327 prepare: () => Effect.die(new Error("SSH preparation is not used.")),
334328 disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]),
335329 });
336 const driver = ConnectionDriver.of({
330 const driver = ConnectionDriver.ConnectionDriver.of({
337331 connect: (entry, reportProgress) =>
338332 Effect.gen(function* () {
339333 const target = entry.target;
......@@ -350,12 +344,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
350344 yield* Ref.update(sessions, (current) => [...current, { closed }]);
351345 const session = yield* Effect.acquireRelease(
352346 Effect.succeed({
353 client: {} as RpcSession["client"],
347 client: {} as RpcSession.RpcSession["client"],
354348 initialConfig: Effect.die(new Error("Config is not used by registry tests.")),
355349 ready: Effect.void,
356350 probe: Effect.void,
357351 closed: Deferred.await(closed),
358 } satisfies RpcSession),
352 } satisfies RpcSession.RpcSession),
359353 () => Ref.update(releasedSessions, (count) => count + 1),
360354 );
361355 yield* reportProgress({ stage: "synchronizing", prepared });
......@@ -364,21 +358,24 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
364358 }),
365359 });
366360
367 const cacheLayer = Layer.succeed(EnvironmentCacheStore, cacheStore);
368 const layer = environmentRegistryLayer.pipe(
361 const cacheLayer = Layer.succeed(Persistence.EnvironmentCacheStore, cacheStore);
362 const layer = EnvironmentRegistry.layer.pipe(
369363 Layer.provide(
370364 Layer.mergeAll(
371 Layer.succeed(ConnectionTargetStore, targetStore),
372 Layer.succeed(ConnectionRegistrationStore, registrationStore),
373 Layer.succeed(ConnectionProfileStore, profileStore),
374 Layer.succeed(ConnectionCredentialStore, credentialStore),
375 Layer.succeed(RemoteDpopAccessTokenStore, tokenStore),
376 Layer.succeed(SshEnvironmentGateway, sshGateway),
377 Layer.succeed(Connectivity, connectivity),
378 Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })),
379 Layer.succeed(ConnectionDriver, driver),
365 Layer.succeed(Persistence.ConnectionTargetStore, targetStore),
366 Layer.succeed(Persistence.ConnectionRegistrationStore, registrationStore),
367 Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore),
368 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),
369 Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore),
370 Layer.succeed(ClientCapabilities.SshEnvironmentGateway, sshGateway),
371 Layer.succeed(Connectivity.Connectivity, connectivity),
372 Layer.succeed(
373 ConnectionWakeups.ConnectionWakeups,
374 ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }),
375 ),
376 Layer.succeed(ConnectionDriver.ConnectionDriver, driver),
380377 cacheLayer,
381 Layer.succeed(EnvironmentOwnedDataCleanup, ownedDataCleanup),
378 Layer.succeed(Persistence.EnvironmentOwnedDataCleanup, ownedDataCleanup),
382379 ),
383380 ),
384381 );
......@@ -401,7 +398,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
401398});
402399
403400function awaitConnectionState(
404 registry: EnvironmentRegistry["Service"],
401 registry: EnvironmentRegistry.EnvironmentRegistry["Service"],
405402 environmentId: EnvironmentId,
406403 predicate: (state: SupervisorConnectionState) => boolean,
407404) {
......@@ -422,7 +419,7 @@ describe("EnvironmentRegistry", () => {
422419 const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]);
423420
424421 yield* Effect.gen(function* () {
425 const registry = yield* EnvironmentRegistry;
422 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
426423 const entry = (yield* SubscriptionRef.get(registry.entries)).get(
427424 SSH_CONNECTION.environmentId,
428425 );
......@@ -438,7 +435,7 @@ describe("EnvironmentRegistry", () => {
438435 const harness = yield* makeHarness([]);
439436
440437 yield* Effect.gen(function* () {
441 const registry = yield* EnvironmentRegistry;
438 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
442439 const offline = yield* Effect.forkChild(
443440 SubscriptionRef.changes(registry.networkStatus).pipe(
444441 Stream.filter((status) => status === "offline"),
......@@ -471,7 +468,7 @@ describe("EnvironmentRegistry", () => {
471468 });
472469
473470 yield* Effect.gen(function* () {
474 const registry = yield* EnvironmentRegistry;
471 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
475472 const start = yield* Effect.forkChild(registry.start);
476473
477474 yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second"));
......@@ -487,7 +484,7 @@ describe("EnvironmentRegistry", () => {
487484 Effect.gen(function* () {
488485 const harness = yield* makeHarness([TARGET]);
489486 yield* Effect.gen(function* () {
490 const registry = yield* EnvironmentRegistry;
487 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
491488 yield* registry.start;
492489 yield* awaitConnectionState(
493490 registry,
......@@ -499,7 +496,7 @@ describe("EnvironmentRegistry", () => {
499496 .runStream(
500497 TARGET.environmentId,
501498 Stream.unwrap(
502 EnvironmentSupervisor.pipe(
499 EnvironmentSupervisor.EnvironmentSupervisor.pipe(
503500 Effect.map((supervisor) =>
504501 Stream.concat(
505502 Stream.fromEffect(SubscriptionRef.get(supervisor.state)),
......@@ -527,7 +524,7 @@ describe("EnvironmentRegistry", () => {
527524 Effect.gen(function* () {
528525 const harness = yield* makeHarness([TARGET]);
529526 yield* Effect.gen(function* () {
530 const registry = yield* EnvironmentRegistry;
527 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
531528 yield* registry.start;
532529 yield* awaitConnectionState(
533530 registry,
......@@ -554,7 +551,7 @@ describe("EnvironmentRegistry", () => {
554551 active!.closed,
555552 new ConnectionTransientError({
556553 reason: "transport",
557 message: "Disconnected.",
554 detail: "Disconnected.",
558555 }),
559556 );
560557 yield* Fiber.join(retryFiber);
......@@ -578,7 +575,7 @@ describe("EnvironmentRegistry", () => {
578575 const harness = yield* makeHarness([]);
579576
580577 yield* Effect.gen(function* () {
581 const registry = yield* EnvironmentRegistry;
578 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
582579 yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET }));
583580 yield* awaitConnectionState(
584581 registry,
......@@ -603,7 +600,7 @@ describe("EnvironmentRegistry", () => {
603600 const harness = yield* makeHarness([RELAY_TARGET]);
604601
605602 yield* Effect.gen(function* () {
606 const registry = yield* EnvironmentRegistry;
603 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
607604 const firstObserved = yield* Deferred.make<void>();
608605 const secondObserved = yield* Deferred.make<void>();
609606 const labels = yield* Ref.make<ReadonlyArray<string>>([]);
......@@ -619,7 +616,7 @@ describe("EnvironmentRegistry", () => {
619616 .followStream(
620617 RELAY_TARGET.environmentId,
621618 Stream.unwrap(
622 EnvironmentSupervisor.pipe(
619 EnvironmentSupervisor.EnvironmentSupervisor.pipe(
623620 Effect.map((supervisor) =>
624621 Stream.concat(Stream.succeed(supervisor.target.label), Stream.never),
625622 ),
......@@ -655,7 +652,7 @@ describe("EnvironmentRegistry", () => {
655652 const harness = yield* makeHarness([]);
656653
657654 yield* Effect.gen(function* () {
658 const registry = yield* EnvironmentRegistry;
655 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
659656 yield* registry.retryNow(EnvironmentId.make("removed-environment"));
660657 }).pipe(Effect.provide(harness.layer), Effect.scoped);
661658 }),
......@@ -670,7 +667,7 @@ describe("EnvironmentRegistry", () => {
670667 );
671668
672669 yield* Effect.gen(function* () {
673 const registry = yield* EnvironmentRegistry;
670 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
674671 yield* registry.removeRelayEnvironments();
675672
676673 const targets = yield* Ref.get(harness.storedTargets);
......@@ -695,7 +692,7 @@ describe("EnvironmentRegistry", () => {
695692 const harness = yield* makeHarness([RELAY_TARGET], [], [], {
696693 beforeRegistrationRemove: () =>
697694 Effect.fail(
698 new ConnectionPersistenceError({
695 new Persistence.ConnectionPersistenceError({
699696 operation: "remove-connection",
700697 message: "Storage is unavailable.",
701698 }),
......@@ -703,7 +700,7 @@ describe("EnvironmentRegistry", () => {
703700 });
704701
705702 yield* Effect.gen(function* () {
706 const registry = yield* EnvironmentRegistry;
703 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
707704 yield* registry.start;
708705 yield* awaitConnectionState(
709706 registry,
......@@ -730,7 +727,7 @@ describe("EnvironmentRegistry", () => {
730727 const harness = yield* makeHarness([]);
731728
732729 yield* Effect.gen(function* () {
733 const registry = yield* EnvironmentRegistry;
730 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
734731 yield* registry.register(
735732 new BearerConnectionRegistration({
736733 target: BEARER_TARGET,
......@@ -760,7 +757,7 @@ describe("EnvironmentRegistry", () => {
760757 const harness = yield* makeHarness([]);
761758
762759 yield* Effect.gen(function* () {
763 const registry = yield* EnvironmentRegistry;
760 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
764761 yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET }));
765762 yield* awaitConnectionState(
766763 registry,
......@@ -791,7 +788,7 @@ describe("EnvironmentRegistry", () => {
791788 const harness = yield* makeHarness([shadowedTarget]);
792789
793790 yield* Effect.gen(function* () {
794 const registry = yield* EnvironmentRegistry;
791 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
795792 yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET }));
796793
797794 expect(
......@@ -825,7 +822,7 @@ describe("EnvironmentRegistry", () => {
825822 });
826823
827824 yield* Effect.gen(function* () {
828 const registry = yield* EnvironmentRegistry;
825 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
829826 const persistedRegistration = yield* registry
830827 .register(new RelayConnectionRegistration({ target: shadowedTarget }))
831828 .pipe(Effect.forkChild({ startImmediately: true }));
......@@ -864,7 +861,7 @@ describe("EnvironmentRegistry", () => {
864861 });
865862
866863 yield* Effect.gen(function* () {
867 const registry = yield* EnvironmentRegistry;
864 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
868865 yield* registry.start;
869866 yield* awaitConnectionState(
870867 registry,
......@@ -894,7 +891,7 @@ describe("EnvironmentRegistry", () => {
894891 const harness = yield* makeHarness([]);
895892
896893 yield* Effect.gen(function* () {
897 const registry = yield* EnvironmentRegistry;
894 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
898895 const registration = new PrimaryConnectionRegistration({ target: TARGET });
899896 yield* registry.registerPlatform(registration);
900897 yield* awaitConnectionState(
......@@ -924,7 +921,7 @@ describe("EnvironmentRegistry", () => {
924921 );
925922
926923 yield* Effect.gen(function* () {
927 const registry = yield* EnvironmentRegistry;
924 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
928925 yield* registry.start;
929926 yield* registry.remove(SSH_CONNECTION.environmentId);
930927
packages/client-runtime/src/connection/registry.ts+107-102
......@@ -1,4 +1,4 @@
1import type { EnvironmentId } from "@t3tools/contracts";
1import { EnvironmentId } from "@t3tools/contracts";
22import * as Context from "effect/Context";
33import * as Effect from "effect/Effect";
44import * as Equal from "effect/Equal";
......@@ -12,120 +12,124 @@ import * as Semaphore from "effect/Semaphore";
1212import * as Stream from "effect/Stream";
1313import * as SubscriptionRef from "effect/SubscriptionRef";
1414
15import { SshEnvironmentGateway } from "../platform/capabilities.ts";
15import * as ClientCapabilities from "../platform/capabilities.ts";
1616import {
1717 type ConnectionCatalogEntry,
1818 type ConnectionRegistration,
19 ConnectionProfileStore,
2019 type PrimaryConnectionRegistration,
2120 SshConnectionProfile,
2221 connectionRegistrationCatalogEntry,
2322} from "./catalog.ts";
24import { Connectivity } from "./connectivity.ts";
23import * as ConnectionProfileStore from "./profileStore.ts";
24import * as Connectivity from "./connectivity.ts";
2525import type {
2626 ConnectionAttemptError,
2727 ConnectionTarget,
2828 NetworkStatus,
2929 SupervisorConnectionState,
3030} from "./model.ts";
31import {
32 type ConnectionPersistenceError,
33 ConnectionRegistrationStore,
34 ConnectionTargetStore,
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";
31import * as Persistence from "../platform/persistence.ts";
32import * as EnvironmentSupervisor from "./supervisor.ts";
33import * as ConnectionDriver from "./driver.ts";
34import * as ConnectionWakeups from "./wakeups.ts";
4535
4636const isSshConnectionProfile = Schema.is(SshConnectionProfile);
4737
4838export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass<EnvironmentNotRegisteredError>()(
4939 "EnvironmentNotRegisteredError",
5040 {
51 environmentId: Schema.String,
52 message: Schema.String,
41 environmentId: EnvironmentId,
5342 },
54) {}
43) {
44 override get message(): string {
45 return `Environment ${this.environmentId} is not registered.`;
46 }
47}
5548
5649export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass<PlatformEnvironmentRemovalError>()(
5750 "PlatformEnvironmentRemovalError",
5851 {
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: (
7552 environmentId: EnvironmentId,
76 ) => Effect.Effect<
77 void,
78 | ConnectionPersistenceError
79 | ConnectionAttemptError
80 | EnvironmentNotRegisteredError
81 | PlatformEnvironmentRemovalError
82 >;
83 readonly removeRelayEnvironments: () => Effect.Effect<
84 void,
85 ConnectionPersistenceError | ConnectionAttemptError | PlatformEnvironmentRemovalError
86 >;
87 readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>;
88 readonly state: (
89 environmentId: EnvironmentId,
90 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;
91 readonly stateChanges: (
92 environmentId: EnvironmentId,
93 ) => Stream.Stream<SupervisorConnectionState, EnvironmentNotRegisteredError>;
94 readonly run: <A, E, R>(
95 environmentId: EnvironmentId,
96 effect: Effect.Effect<A, E, R>,
97 ) => Effect.Effect<A, E | EnvironmentNotRegisteredError, Exclude<R, EnvironmentSupervisor>>;
98 readonly runStream: <A, E, R>(
99 environmentId: EnvironmentId,
100 stream: Stream.Stream<A, E, R>,
101 ) => Stream.Stream<A, E | EnvironmentNotRegisteredError, Exclude<R, EnvironmentSupervisor>>;
102 readonly followStream: <A, E, R>(
103 environmentId: EnvironmentId,
104 stream: Stream.Stream<A, E, R>,
105 ) => Stream.Stream<A, E, Exclude<R, EnvironmentSupervisor>>;
53 },
54) {
55 override get message(): string {
56 return `Platform-managed environment ${this.environmentId} cannot be removed.`;
57 }
10658}
10759
10860export class EnvironmentRegistry extends Context.Service<
10961 EnvironmentRegistry,
110 EnvironmentRegistryService
62 {
63 readonly entries: SubscriptionRef.SubscriptionRef<
64 ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>
65 >;
66 readonly networkStatus: SubscriptionRef.SubscriptionRef<NetworkStatus>;
67 readonly start: Effect.Effect<void>;
68 readonly register: (
69 registration: ConnectionRegistration,
70 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
71 readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>;
72 readonly remove: (
73 environmentId: EnvironmentId,
74 ) => Effect.Effect<
75 void,
76 | Persistence.ConnectionPersistenceError
77 | ConnectionAttemptError
78 | EnvironmentNotRegisteredError
79 | PlatformEnvironmentRemovalError
80 >;
81 readonly removeRelayEnvironments: () => Effect.Effect<
82 void,
83 | Persistence.ConnectionPersistenceError
84 | ConnectionAttemptError
85 | PlatformEnvironmentRemovalError
86 >;
87 readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>;
88 readonly state: (
89 environmentId: EnvironmentId,
90 ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>;
91 readonly stateChanges: (
92 environmentId: EnvironmentId,
93 ) => Stream.Stream<SupervisorConnectionState, EnvironmentNotRegisteredError>;
94 readonly run: <A, E, R>(
95 environmentId: EnvironmentId,
96 effect: Effect.Effect<A, E, R>,
97 ) => Effect.Effect<
98 A,
99 E | EnvironmentNotRegisteredError,
100 Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>
101 >;
102 readonly runStream: <A, E, R>(
103 environmentId: EnvironmentId,
104 stream: Stream.Stream<A, E, R>,
105 ) => Stream.Stream<
106 A,
107 E | EnvironmentNotRegisteredError,
108 Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>
109 >;
110 readonly followStream: <A, E, R>(
111 environmentId: EnvironmentId,
112 stream: Stream.Stream<A, E, R>,
113 ) => Stream.Stream<A, E, Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>>;
114 }
111115>()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {}
112116
113117interface EnvironmentServiceScope {
114118 readonly entry: ConnectionCatalogEntry;
115 readonly supervisor: EnvironmentSupervisorService;
119 readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"];
116120 readonly scope: Scope.Closeable;
117121}
118122
119const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* () {
120 const storage = yield* ConnectionTargetStore;
121 const registrations = yield* ConnectionRegistrationStore;
122 const cache = yield* EnvironmentCacheStore;
123 const ownedDataCleanup = yield* EnvironmentOwnedDataCleanup;
124 const profiles = yield* ConnectionProfileStore;
125 const connectivity = yield* Connectivity;
126 const driver = yield* ConnectionDriver;
127 const wakeups = yield* ConnectionWakeups;
128 const ssh = yield* SshEnvironmentGateway;
123export const make = Effect.gen(function* () {
124 const storage = yield* Persistence.ConnectionTargetStore;
125 const registrations = yield* Persistence.ConnectionRegistrationStore;
126 const cache = yield* Persistence.EnvironmentCacheStore;
127 const ownedDataCleanup = yield* Persistence.EnvironmentOwnedDataCleanup;
128 const profiles = yield* ConnectionProfileStore.ConnectionProfileStore;
129 const connectivity = yield* Connectivity.Connectivity;
130 const driver = yield* ConnectionDriver.ConnectionDriver;
131 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
132 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
129133 const persistedTargets = yield* storage.list;
130134 const initialEntries = new Map(
131135 yield* Effect.forEach(
......@@ -215,7 +219,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function*
215219 if (entry === undefined) {
216220 return yield* new EnvironmentNotRegisteredError({
217221 environmentId,
218 message: `Environment ${environmentId} is not registered.`,
219222 });
220223 }
221224 return entry;
......@@ -241,12 +244,12 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function*
241244 Effect.gen(function* () {
242245 const environmentId = entry.target.environmentId;
243246 const scope = yield* Scope.make();
244 const supervisor = yield* makeEnvironmentSupervisor(entry, {
247 const supervisor = yield* EnvironmentSupervisor.make(entry, {
245248 initiallyDesired: false,
246249 }).pipe(
247 Effect.provideService(Connectivity, connectivity),
248 Effect.provideService(ConnectionDriver, driver),
249 Effect.provideService(ConnectionWakeups, wakeups),
250 Effect.provideService(Connectivity.Connectivity, connectivity),
251 Effect.provideService(ConnectionDriver.ConnectionDriver, driver),
252 Effect.provideService(ConnectionWakeups.ConnectionWakeups, wakeups),
250253 Scope.provide(scope),
251254 Effect.onError(() => Scope.close(scope, Exit.void)),
252255 );
......@@ -280,28 +283,30 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function*
280283 );
281284 });
282285
283 const run: EnvironmentRegistryService["run"] = Effect.fn("EnvironmentRegistry.run")(function* <
284 A,
285 E,
286 R,
287 >(environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) {
288 const supervisor = yield* acquireSupervisor(environmentId);
289 return yield* Effect.provideService(effect, EnvironmentSupervisor, supervisor);
290 });
286 const run: EnvironmentRegistry["Service"]["run"] = Effect.fn("EnvironmentRegistry.run")(
287 function* <A, E, R>(environmentId: EnvironmentId, effect: Effect.Effect<A, E, R>) {
288 const supervisor = yield* acquireSupervisor(environmentId);
289 return yield* Effect.provideService(
290 effect,
291 EnvironmentSupervisor.EnvironmentSupervisor,
292 supervisor,
293 );
294 },
295 );
291296
292 const runStream: EnvironmentRegistryService["runStream"] = <A, E, R>(
297 const runStream: EnvironmentRegistry["Service"]["runStream"] = <A, E, R>(
293298 environmentId: EnvironmentId,
294299 stream: Stream.Stream<A, E, R>,
295300 ) =>
296301 Stream.unwrap(
297302 acquireSupervisor(environmentId).pipe(
298303 Effect.map((supervisor) =>
299 Stream.provideService(stream, EnvironmentSupervisor, supervisor),
304 Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
300305 ),
301306 ),
302307 );
303308
304 const followStream: EnvironmentRegistryService["followStream"] = <A, E, R>(
309 const followStream: EnvironmentRegistry["Service"]["followStream"] = <A, E, R>(
305310 environmentId: EnvironmentId,
306311 stream: Stream.Stream<A, E, R>,
307312 ) =>
......@@ -320,7 +325,11 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function*
320325 Effect.match({
321326 onFailure: () => Stream.empty,
322327 onSuccess: (supervisor) =>
323 Stream.provideService(stream, EnvironmentSupervisor, supervisor),
328 Stream.provideService(
329 stream,
330 EnvironmentSupervisor.EnvironmentSupervisor,
331 supervisor,
332 ),
324333 }),
325334 ),
326335 ),
......@@ -443,7 +452,6 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function*
443452 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
444453 return yield* new PlatformEnvironmentRemovalError({
445454 environmentId,
446 message: "Platform-managed environments cannot be removed.",
447455 });
448456 }
449457 const target = (yield* getEntry(environmentId)).target;
......@@ -532,7 +540,7 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function*
532540 followStream(
533541 environmentId,
534542 Stream.unwrap(
535 EnvironmentSupervisor.pipe(
543 EnvironmentSupervisor.EnvironmentSupervisor.pipe(
536544 Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)),
537545 ),
538546 ),
......@@ -570,7 +578,4 @@ const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function*
570578 });
571579});
572580
573export const environmentRegistryLayer = Layer.effect(
574 EnvironmentRegistry,
575 makeEnvironmentRegistry(),
576);
581export const layer = Layer.effect(EnvironmentRegistry, make);
packages/client-runtime/src/connection/resolver.test.ts+43-46
......@@ -8,30 +8,19 @@ import * as Option from "effect/Option";
88import * as Ref from "effect/Ref";
99import * as Tracer from "effect/Tracer";
1010
11import {
12 ManagedRelayClient,
13 ManagedRelayClientError,
14 ManagedRelayRequestTimeoutError,
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";
11import * as ManagedRelay from "../relay/managedRelay.ts";
12import * as ConnectionResolver from "./resolver.ts";
13import * as ClientCapabilities from "../platform/capabilities.ts";
14import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
2515import {
2616 BearerConnectionCredential,
2717 BearerConnectionProfile,
2818 type ConnectionCatalogEntry,
29 ConnectionCredentialStore,
30 ConnectionProfileStore,
3119 SshConnectionProfile,
3220 type ConnectionCredential,
3321 type ConnectionProfile,
3422} from "./catalog.ts";
23import * as ConnectionCredentialStore from "./credentialStore.ts";
3524import {
3625 BearerConnectionTarget,
3726 ConnectionTransientError,
......@@ -40,6 +29,7 @@ import {
4029 SshConnectionTarget,
4130 type ConnectionTarget,
4231} from "./model.ts";
32import * as ConnectionProfileStore from "./profileStore.ts";
4333
4434const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
4535const ENDPOINT = {
......@@ -79,8 +69,10 @@ function collectingTracer(spans: Array<string>): Tracer.Tracer {
7969 });
8070}
8171
82function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectEnvironment"]) {
83 return ManagedRelayClient.of({
72function relayClient(
73 connectEnvironment: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"],
74) {
75 return ManagedRelay.ManagedRelayClient.of({
8476 relayUrl: "https://relay.example.test",
8577 listEnvironments: () => unsupported("listEnvironments"),
8678 listDevices: () => unsupported("listDevices"),
......@@ -99,29 +91,29 @@ function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectE
9991const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: {
10092 readonly profiles?: ReadonlyArray<ConnectionProfile>;
10193 readonly credentials?: ReadonlyArray<readonly [string, ConnectionCredential]>;
102 readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"];
103 readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];
104 readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
94 readonly connectEnvironment?: ManagedRelay.ManagedRelayClient["Service"]["connectEnvironment"];
95 readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"];
96 readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"];
10597 readonly primaryBearerToken?: string;
106 readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"];
98 readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"];
10799}) => {
108100 const profiles = new Map(
109101 (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]),
110102 );
111103 const credentials = new Map(options?.credentials ?? []);
112104
113 const profileStore = ConnectionProfileStore.of({
105 const profileStore = ConnectionProfileStore.ConnectionProfileStore.of({
114106 get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))),
115107 put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)),
116108 remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)),
117109 });
118 const credentialStore = ConnectionCredentialStore.of({
110 const credentialStore = ConnectionCredentialStore.ConnectionCredentialStore.of({
119111 get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))),
120112 put: (connectionId, credential) =>
121113 Effect.sync(() => void credentials.set(connectionId, credential)),
122114 remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)),
123115 });
124 const remote = RemoteEnvironmentAuthorization.of({
116 const remote = RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
125117 authorizeBearer:
126118 options?.authorizeBearer ??
127119 ((input) =>
......@@ -151,7 +143,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
151143 }),
152144 )),
153145 });
154 const ssh = SshEnvironmentGateway.of({
146 const ssh = ClientCapabilities.SshEnvironmentGateway.of({
155147 provision: () => Effect.die("unused"),
156148 prepare:
157149 options?.prepareSsh ??
......@@ -169,23 +161,28 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
169161 });
170162
171163 const dependencies = Layer.mergeAll(
172 Layer.succeed(ConnectionProfileStore, profileStore),
173 Layer.succeed(ConnectionCredentialStore, credentialStore),
174 Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })),
164 Layer.succeed(ConnectionProfileStore.ConnectionProfileStore, profileStore),
165 Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore),
175166 Layer.succeed(
176 PrimaryEnvironmentAuth,
177 PrimaryEnvironmentAuth.of({
167 ClientCapabilities.CloudSession,
168 ClientCapabilities.CloudSession.of({ clerkToken: Effect.succeed("clerk-session") }),
169 ),
170 Layer.succeed(
171 ClientCapabilities.PrimaryEnvironmentAuth,
172 ClientCapabilities.PrimaryEnvironmentAuth.of({
178173 bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)),
179174 }),
180175 ),
181176 Layer.succeed(
182 RelayDeviceIdentity,
183 RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }),
177 ClientCapabilities.RelayDeviceIdentity,
178 ClientCapabilities.RelayDeviceIdentity.of({
179 deviceId: Effect.succeed(Option.some("device-1")),
180 }),
184181 ),
185 Layer.succeed(RemoteEnvironmentAuthorization, remote),
186 Layer.succeed(SshEnvironmentGateway, ssh),
182 Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote),
183 Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh),
187184 Layer.succeed(
188 ManagedRelayClient,
185 ManagedRelay.ManagedRelayClient,
189186 relayClient(
190187 options?.connectEnvironment ??
191188 ((input) =>
......@@ -199,14 +196,14 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
199196 ),
200197 );
201198
202 return Effect.succeed(connectionResolverLayer.pipe(Layer.provide(dependencies)));
199 return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(dependencies)));
203200});
204201
205202describe("ConnectionResolver", () => {
206203 it.effect("prepares a primary environment without remote capabilities", () =>
207204 Effect.gen(function* () {
208205 const brokerLayer = yield* makeDependencies();
209 const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer));
206 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
210207 const target = new PrimaryConnectionTarget({
211208 environmentId: ENVIRONMENT_ID,
212209 label: "Primary",
......@@ -244,7 +241,7 @@ describe("ConnectionResolver", () => {
244241 }),
245242 ),
246243 });
247 const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer));
244 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
248245 const target = new PrimaryConnectionTarget({
249246 environmentId: ENVIRONMENT_ID,
250247 label: "Primary",
......@@ -292,7 +289,7 @@ describe("ConnectionResolver", () => {
292289 }),
293290 ),
294291 });
295 const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer));
292 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
296293
297294 expect(
298295 (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl,
......@@ -349,7 +346,7 @@ describe("ConnectionResolver", () => {
349346 }),
350347 ),
351348 });
352 const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer));
349 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
353350
354351 expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop");
355352 expect(yield* Ref.get(relayInputs)).toEqual([
......@@ -387,7 +384,7 @@ describe("ConnectionResolver", () => {
387384 Effect.withSpan("test.remote.authorizeDpop"),
388385 ),
389386 });
390 const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer));
387 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
391388
392389 yield* broker
393390 .prepare(catalogEntry(target))
......@@ -431,7 +428,7 @@ describe("ConnectionResolver", () => {
431428 }),
432429 ),
433430 });
434 const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer));
431 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
435432
436433 expect(
437434 (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl,
......@@ -449,15 +446,15 @@ describe("ConnectionResolver", () => {
449446 const brokerLayer = yield* makeDependencies({
450447 connectEnvironment: () =>
451448 Effect.fail(
452 new ManagedRelayClientError({
449 new ManagedRelay.ManagedRelayClientError({
453450 message: "Relay timed out.",
454 cause: new ManagedRelayRequestTimeoutError({
451 cause: new ManagedRelay.ManagedRelayRequestTimeoutError({
455452 message: "Relay timed out.",
456453 }),
457454 }),
458455 ),
459456 });
460 const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer));
457 const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer));
461458 const error = yield* Effect.flip(broker.prepare(catalogEntry(target)));
462459
463460 expect(error).toBeInstanceOf(ConnectionTransientError);
packages/client-runtime/src/connection/resolver.ts+45-51
......@@ -6,22 +6,16 @@ import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import * as Schema from "effect/Schema";
88
9import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
10import { ManagedRelayClient } from "../relay/managedRelay.ts";
11import {
12 CloudSession,
13 PrimaryEnvironmentAuth,
14 RelayDeviceIdentity,
15 SshEnvironmentGateway,
16} from "../platform/capabilities.ts";
9import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
10import * as ManagedRelay from "../relay/managedRelay.ts";
11import * as ClientCapabilities from "../platform/capabilities.ts";
1712import {
1813 BearerConnectionCredential,
1914 BearerConnectionProfile,
2015 type ConnectionCatalogEntry,
21 ConnectionCredentialStore,
22 ConnectionProfileStore,
2316 SshConnectionProfile,
2417} from "./catalog.ts";
18import * as ConnectionCredentialStore from "./credentialStore.ts";
2519import {
2620 credentialMissingError,
2721 environmentMismatchError,
......@@ -37,6 +31,7 @@ import type {
3731 SshConnectionTarget,
3832} from "./model.ts";
3933import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
34import * as ConnectionProfileStore from "./profileStore.ts";
4035
4136export class ConnectionResolver extends Context.Service<
4237 ConnectionResolver,
......@@ -60,8 +55,8 @@ function primarySocketUrl(target: PrimaryConnectionTarget): string {
6055}
6156
6257const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () {
63 const auth = yield* PrimaryEnvironmentAuth;
64 const remote = yield* RemoteEnvironmentAuthorization;
58 const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth;
59 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
6560
6661 return Effect.fn("clientRuntime.connection.broker.primary")(function* (
6762 target: PrimaryConnectionTarget,
......@@ -92,8 +87,8 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary
9287});
9388
9489const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () {
95 const credentials = yield* ConnectionCredentialStore;
96 const remote = yield* RemoteEnvironmentAuthorization;
90 const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
91 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
9792
9893 return Effect.fn("clientRuntime.connection.broker.bearer")(function* (
9994 entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget },
......@@ -106,7 +101,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
106101 if (!isBearerProfile(profile)) {
107102 return yield* new ConnectionBlockedError({
108103 reason: "configuration",
109 message: `Connection profile ${target.connectionId} is not a bearer connection.`,
104 detail: `Connection profile ${target.connectionId} is not a bearer connection.`,
110105 });
111106 }
112107 if (profile.environmentId !== target.environmentId) {
......@@ -144,10 +139,10 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
144139});
145140
146141const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () {
147 const relay = yield* ManagedRelayClient;
148 const session = yield* CloudSession;
149 const identity = yield* RelayDeviceIdentity;
150 const remote = yield* RemoteEnvironmentAuthorization;
142 const relay = yield* ManagedRelay.ManagedRelayClient;
143 const session = yield* ClientCapabilities.CloudSession;
144 const identity = yield* ClientCapabilities.RelayDeviceIdentity;
145 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
151146
152147 return Effect.fnUntraced(
153148 function* (target: RelayConnectionTarget) {
......@@ -192,9 +187,9 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f
192187});
193188
194189const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () {
195 const profiles = yield* ConnectionProfileStore;
196 const ssh = yield* SshEnvironmentGateway;
197 const remote = yield* RemoteEnvironmentAuthorization;
190 const profiles = yield* ConnectionProfileStore.ConnectionProfileStore;
191 const ssh = yield* ClientCapabilities.SshEnvironmentGateway;
192 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
198193
199194 return Effect.fn("clientRuntime.connection.broker.ssh")(function* (
200195 entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget },
......@@ -207,7 +202,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct
207202 if (!isSshProfile(profile)) {
208203 return yield* new ConnectionBlockedError({
209204 reason: "configuration",
210 message: `Connection profile ${target.connectionId} is not an SSH connection.`,
205 detail: `Connection profile ${target.connectionId} is not an SSH connection.`,
211206 });
212207 }
213208 if (profile.environmentId !== target.environmentId) {
......@@ -246,34 +241,33 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct
246241 });
247242});
248243
249export const connectionResolverLayer = Layer.effect(
250 ConnectionResolver,
251 Effect.gen(function* () {
252 const primary = yield* makePrimaryBroker();
253 const bearer = yield* makeBearerBroker();
254 const relay = yield* makeRelayBroker();
255 const ssh = yield* makeSshBroker();
244export const make = Effect.gen(function* () {
245 const primary = yield* makePrimaryBroker();
246 const bearer = yield* makeBearerBroker();
247 const relay = yield* makeRelayBroker();
248 const ssh = yield* makeSshBroker();
256249
257 const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* (
258 entry: ConnectionCatalogEntry,
259 ) {
260 const target: ConnectionTarget = entry.target;
261 yield* Effect.annotateCurrentSpan({
262 "connection.environment.id": target.environmentId,
263 "connection.target.kind": target._tag,
264 });
265 switch (target._tag) {
266 case "PrimaryConnectionTarget":
267 return yield* primary(target);
268 case "BearerConnectionTarget":
269 return yield* bearer({ ...entry, target });
270 case "RelayConnectionTarget":
271 return yield* relay(target);
272 case "SshConnectionTarget":
273 return yield* ssh({ ...entry, target });
274 }
250 const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* (
251 entry: ConnectionCatalogEntry,
252 ) {
253 const target: ConnectionTarget = entry.target;
254 yield* Effect.annotateCurrentSpan({
255 "connection.environment.id": target.environmentId,
256 "connection.target.kind": target._tag,
275257 });
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 });
278 }),
279);
273export const layer = Layer.effect(ConnectionResolver, make);
packages/client-runtime/src/connection/supervisor.test.ts+43-44
......@@ -13,12 +13,8 @@ import * as Tracer from "effect/Tracer";
1313
1414import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
1515import type { ConnectionCatalogEntry } from "./catalog.ts";
16import { Connectivity } from "./connectivity.ts";
17import {
18 ConnectionDriver,
19 type ConnectionDriverProgress,
20 type EnvironmentConnectionLease,
21} from "./driver.ts";
16import * as Connectivity from "./connectivity.ts";
17import * as ConnectionDriver from "./driver.ts";
2218import {
2319 ConnectionBlockedError,
2420 ConnectionTransientError,
......@@ -30,9 +26,9 @@ import {
3026 type PreparedConnection,
3127 type SupervisorConnectionState,
3228} from "./model.ts";
33import type { RpcSession } from "../rpc/session.ts";
34import { makeEnvironmentSupervisor } from "./supervisor.ts";
35import { ConnectionWakeups } from "./wakeups.ts";
29import * as RpcSession from "../rpc/session.ts";
30import * as EnvironmentSupervisor from "./supervisor.ts";
31import * as ConnectionWakeups from "./wakeups.ts";
3632
3733const TARGET = new PrimaryConnectionTarget({
3834 environmentId: EnvironmentId.make("environment-1"),
......@@ -70,14 +66,14 @@ const TEST_RPC_CLIENT = {} as WsRpcProtocolClient;
7066function transient(message = "Connection failed.") {
7167 return new ConnectionTransientError({
7268 reason: "transport",
73 message,
69 detail: message,
7470 });
7571}
7672
7773function blocked(message = "Authentication required.") {
7874 return new ConnectionBlockedError({
7975 reason: "authentication",
80 message,
76 detail: message,
8177 });
8278}
8379
......@@ -137,7 +133,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
137133 ReadonlyArray<Deferred.Deferred<never, ConnectionTransientError>>
138134 >([]);
139135
140 const connectivity = Connectivity.of({
136 const connectivity = Connectivity.Connectivity.of({
141137 status: SubscriptionRef.get(networkStatus),
142138 changes: SubscriptionRef.changes(networkStatus),
143139 });
......@@ -152,7 +148,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
152148
153149 const connect = Effect.fn("TestConnectionDriver.connect")(function* (
154150 entry: ConnectionCatalogEntry,
155 reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect<void>,
151 reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect<void>,
156152 ) {
157153 const target = entry.target;
158154 yield* reportProgress({ stage: "preparing" });
......@@ -170,27 +166,30 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
170166 ready: options?.ready?.(attempt) ?? Effect.void,
171167 probe: options?.probe?.(attempt) ?? Effect.void,
172168 closed: Deferred.await(closed),
173 } satisfies RpcSession),
169 } satisfies RpcSession.RpcSession),
174170 () => Ref.update(releaseCount, (count) => count + 1),
175171 );
176172
177173 yield* reportProgress({ stage: "synchronizing", prepared });
178174 yield* session.ready;
179 return { prepared, session } satisfies EnvironmentConnectionLease;
175 return { prepared, session } satisfies ConnectionDriver.EnvironmentConnectionLease;
180176 });
181177
182178 const dependencies = Layer.mergeAll(
183 Layer.succeed(Connectivity, connectivity),
179 Layer.succeed(Connectivity.Connectivity, connectivity),
184180 Layer.succeed(
185 ConnectionWakeups,
186 ConnectionWakeups.of({
181 ConnectionWakeups.ConnectionWakeups,
182 ConnectionWakeups.ConnectionWakeups.of({
187183 changes: SubscriptionRef.changes(wakeups).pipe(
188184 Stream.drop(1),
189185 Stream.map((event) => event.reason),
190186 ),
191187 }),
192188 ),
193 Layer.succeed(ConnectionDriver, ConnectionDriver.of({ connect })),
189 Layer.succeed(
190 ConnectionDriver.ConnectionDriver,
191 ConnectionDriver.ConnectionDriver.of({ connect }),
192 ),
194193 );
195194
196195 return {
......@@ -235,7 +234,7 @@ describe("EnvironmentSupervisor", () => {
235234 prepare: (attempt) =>
236235 attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION),
237236 });
238 const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, {
237 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
239238 initiallyDesired: true,
240239 }).pipe(
241240 Effect.provide(harness.dependencies),
......@@ -263,7 +262,7 @@ describe("EnvironmentSupervisor", () => {
263262 it.effect("does not attempt a connection until it is desired", () =>
264263 Effect.gen(function* () {
265264 const harness = yield* makeHarness();
266 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe(
265 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe(
267266 Effect.provide(harness.dependencies),
268267 );
269268
......@@ -275,7 +274,7 @@ describe("EnvironmentSupervisor", () => {
275274 it.effect("does not let the initial connect signal cancel the first attempt", () =>
276275 Effect.gen(function* () {
277276 const harness = yield* makeHarness();
278 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe(
277 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY).pipe(
279278 Effect.provide(harness.dependencies),
280279 );
281280
......@@ -290,7 +289,7 @@ describe("EnvironmentSupervisor", () => {
290289 it.effect("waits while offline and connects immediately when the network returns", () =>
291290 Effect.gen(function* () {
292291 const harness = yield* makeHarness({ networkStatus: "offline" });
293 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
292 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
294293 initiallyDesired: true,
295294 }).pipe(Effect.provide(harness.dependencies));
296295
......@@ -317,7 +316,7 @@ describe("EnvironmentSupervisor", () => {
317316 const harness = yield* makeHarness({
318317 prepare: () => Effect.fail(transient()),
319318 });
320 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
319 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
321320 initiallyDesired: true,
322321 }).pipe(Effect.provide(harness.dependencies));
323322
......@@ -345,7 +344,7 @@ describe("EnvironmentSupervisor", () => {
345344 prepare: (attempt) =>
346345 attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never,
347346 });
348 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
347 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
349348 initiallyDesired: true,
350349 }).pipe(Effect.provide(harness.dependencies));
351350
......@@ -378,7 +377,7 @@ describe("EnvironmentSupervisor", () => {
378377 const harness = yield* makeHarness({
379378 ready: () => Effect.never,
380379 });
381 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
380 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
382381 initiallyDesired: true,
383382 }).pipe(Effect.provide(harness.dependencies));
384383
......@@ -410,7 +409,7 @@ describe("EnvironmentSupervisor", () => {
410409 const harness = yield* makeHarness({
411410 prepare: () => Effect.never,
412411 });
413 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
412 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
414413 initiallyDesired: true,
415414 }).pipe(Effect.provide(harness.dependencies));
416415
......@@ -442,7 +441,7 @@ describe("EnvironmentSupervisor", () => {
442441 ? Effect.die(new Error("Native transport defect."))
443442 : Effect.succeed(PREPARED_CONNECTION),
444443 });
445 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
444 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
446445 initiallyDesired: true,
447446 }).pipe(Effect.provide(harness.dependencies));
448447
......@@ -470,7 +469,7 @@ describe("EnvironmentSupervisor", () => {
470469 prepare: (attempt) =>
471470 attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION),
472471 });
473 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
472 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
474473 initiallyDesired: true,
475474 }).pipe(Effect.provide(harness.dependencies));
476475
......@@ -488,7 +487,7 @@ describe("EnvironmentSupervisor", () => {
488487 prepare: (attempt) =>
489488 attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION),
490489 });
491 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
490 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
492491 initiallyDesired: true,
493492 }).pipe(Effect.provide(harness.dependencies));
494493
......@@ -505,7 +504,7 @@ describe("EnvironmentSupervisor", () => {
505504 it.effect("releases a live session while offline and starts a new generation when online", () =>
506505 Effect.gen(function* () {
507506 const harness = yield* makeHarness();
508 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
507 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
509508 initiallyDesired: true,
510509 }).pipe(Effect.provide(harness.dependencies));
511510
......@@ -534,7 +533,7 @@ describe("EnvironmentSupervisor", () => {
534533 prepare: (attempt) =>
535534 attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION),
536535 });
537 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
536 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
538537 initiallyDesired: true,
539538 }).pipe(Effect.provide(harness.dependencies));
540539
......@@ -553,7 +552,7 @@ describe("EnvironmentSupervisor", () => {
553552 prepare: () =>
554553 Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)),
555554 });
556 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
555 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
557556 initiallyDesired: true,
558557 }).pipe(Effect.provide(harness.dependencies));
559558
......@@ -591,7 +590,7 @@ describe("EnvironmentSupervisor", () => {
591590 it.effect("treats an involuntary session close as transient and reconnects", () =>
592591 Effect.gen(function* () {
593592 const harness = yield* makeHarness();
594 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
593 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
595594 initiallyDesired: true,
596595 }).pipe(Effect.provide(harness.dependencies));
597596
......@@ -617,7 +616,7 @@ describe("EnvironmentSupervisor", () => {
617616 it.effect("keeps escalating backoff when a newly opened session flaps", () =>
618617 Effect.gen(function* () {
619618 const harness = yield* makeHarness();
620 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
619 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
621620 initiallyDesired: true,
622621 }).pipe(Effect.provide(harness.dependencies));
623622
......@@ -663,7 +662,7 @@ describe("EnvironmentSupervisor", () => {
663662 Effect.andThen(Deferred.succeed(probeCalled, undefined)),
664663 ),
665664 });
666 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
665 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
667666 initiallyDesired: true,
668667 }).pipe(Effect.provide(harness.dependencies));
669668
......@@ -684,7 +683,7 @@ describe("EnvironmentSupervisor", () => {
684683 probe: (attempt) =>
685684 attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void,
686685 });
687 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
686 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
688687 initiallyDesired: true,
689688 }).pipe(Effect.provide(harness.dependencies));
690689
......@@ -707,7 +706,7 @@ describe("EnvironmentSupervisor", () => {
707706 const harness = yield* makeHarness({
708707 probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
709708 });
710 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
709 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
711710 initiallyDesired: true,
712711 }).pipe(Effect.provide(harness.dependencies));
713712
......@@ -732,7 +731,7 @@ describe("EnvironmentSupervisor", () => {
732731 const harness = yield* makeHarness({
733732 probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)),
734733 });
735 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
734 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
736735 initiallyDesired: true,
737736 }).pipe(Effect.provide(harness.dependencies));
738737
......@@ -749,7 +748,7 @@ describe("EnvironmentSupervisor", () => {
749748 it.effect("does not churn a healthy session when credentials change", () =>
750749 Effect.gen(function* () {
751750 const harness = yield* makeHarness();
752 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
751 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
753752 initiallyDesired: true,
754753 }).pipe(Effect.provide(harness.dependencies));
755754
......@@ -766,7 +765,7 @@ describe("EnvironmentSupervisor", () => {
766765 it.effect("releases and reconnects a relay session when credentials change", () =>
767766 Effect.gen(function* () {
768767 const harness = yield* makeHarness();
769 const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, {
768 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
770769 initiallyDesired: true,
771770 }).pipe(Effect.provide(harness.dependencies));
772771
......@@ -791,7 +790,7 @@ describe("EnvironmentSupervisor", () => {
791790 ? Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never))
792791 : Effect.succeed(PREPARED_CONNECTION),
793792 });
794 const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, {
793 const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, {
795794 initiallyDesired: true,
796795 }).pipe(Effect.provide(harness.dependencies));
797796
......@@ -807,7 +806,7 @@ describe("EnvironmentSupervisor", () => {
807806 it.effect("explicit disconnect releases the session and returns to available", () =>
808807 Effect.gen(function* () {
809808 const harness = yield* makeHarness();
810 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
809 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
811810 initiallyDesired: true,
812811 }).pipe(Effect.provide(harness.dependencies));
813812
......@@ -824,7 +823,7 @@ describe("EnvironmentSupervisor", () => {
824823 it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () =>
825824 Effect.gen(function* () {
826825 const harness = yield* makeHarness();
827 const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, {
826 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
828827 initiallyDesired: true,
829828 }).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";
1515import * as Tracer from "effect/Tracer";
1616
1717import type { ConnectionCatalogEntry } from "./catalog.ts";
18import { Connectivity } from "./connectivity.ts";
19import {
20 ConnectionDriver,
21 type ConnectionDriverProgress,
22 type EnvironmentConnectionLease,
23} from "./driver.ts";
18import * as Connectivity from "./connectivity.ts";
19import * as ConnectionDriver from "./driver.ts";
2420import {
2521 type ConnectionAttemptError,
2622 type ConnectionTarget,
......@@ -29,8 +25,8 @@ import {
2925 type PreparedConnection,
3026 type SupervisorConnectionState,
3127} from "./model.ts";
32import type { RpcSession } from "../rpc/session.ts";
33import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts";
28import * as RpcSession from "../rpc/session.ts";
29import * as ConnectionWakeups from "./wakeups.ts";
3430
3531const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
3632const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";
......@@ -47,7 +43,7 @@ type SupervisorSignal =
4743 | { readonly _tag: "DisconnectRequested" }
4844 | { readonly _tag: "RetryRequested" }
4945 | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus }
50 | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeup };
46 | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup };
5147
5248interface PendingRetryTrace {
5349 readonly previousAttempt: Tracer.Span;
......@@ -80,7 +76,7 @@ type EstablishmentEvent =
8076 readonly exit: Exit.Exit<
8177 {
8278 readonly attemptSpan: Option.Option<Tracer.Span>;
83 readonly lease: EnvironmentConnectionLease;
79 readonly lease: ConnectionDriver.EnvironmentConnectionLease;
8480 },
8581 TracedAttemptFailure
8682 >;
......@@ -102,16 +98,6 @@ export interface EnvironmentSupervisorOptions {
10298 readonly initiallyDesired?: boolean;
10399}
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
115101function retryDelayMs(failureCount: number): number {
116102 return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000;
117103}
......@@ -199,7 +185,7 @@ function failureFromExit<A>(
199185 failure: {
200186 error: new ConnectionTransientError({
201187 reason: "transport",
202 message: `${target.label} connection failed unexpectedly.`,
188 detail: `${target.label} connection failed unexpectedly.`,
203189 }),
204190 attemptSpan: Option.none(),
205191 },
......@@ -208,34 +194,34 @@ function failureFromExit<A>(
208194
209195export class EnvironmentSupervisor extends Context.Service<
210196 EnvironmentSupervisor,
211 EnvironmentSupervisorService
212>()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {
213 static layer(
214 entry: ConnectionCatalogEntry,
215 options?: EnvironmentSupervisorOptions,
216 ): Layer.Layer<
217 EnvironmentSupervisor,
218 never,
219 Connectivity | ConnectionDriver | ConnectionWakeups
220 > {
221 return Layer.effect(EnvironmentSupervisor, makeEnvironmentSupervisor(entry, options));
197 {
198 readonly target: ConnectionTarget;
199 readonly state: SubscriptionRef.SubscriptionRef<SupervisorConnectionState>;
200 readonly session: SubscriptionRef.SubscriptionRef<Option.Option<RpcSession.RpcSession>>;
201 readonly prepared: SubscriptionRef.SubscriptionRef<Option.Option<PreparedConnection>>;
202 readonly connect: Effect.Effect<void>;
203 readonly disconnect: Effect.Effect<void>;
204 readonly retryNow: Effect.Effect<void>;
222205 }
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* (
226209 entry: ConnectionCatalogEntry,
227210 options?: EnvironmentSupervisorOptions,
228211): Effect.fn.Return<
229 EnvironmentSupervisorService,
212 EnvironmentSupervisor["Service"],
230213 never,
231 Connectivity | ConnectionDriver | Scope.Scope | ConnectionWakeups
214 | Connectivity.Connectivity
215 | ConnectionDriver.ConnectionDriver
216 | Scope.Scope
217 | ConnectionWakeups.ConnectionWakeups
232218> {
233219 const target = entry.target;
234220 yield* annotateTarget(target);
235221
236 const connectivity = yield* Connectivity;
237 const driver = yield* ConnectionDriver;
238 const wakeups = yield* ConnectionWakeups;
222 const connectivity = yield* Connectivity.Connectivity;
223 const driver = yield* ConnectionDriver.ConnectionDriver;
224 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
239225 const initialIntent: SupervisorIntent = {
240226 desired: options?.initiallyDesired ?? false,
241227 network: yield* connectivity.status,
......@@ -249,7 +235,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")
249235 ? offlineState(initialIntent, 0, 0, null)
250236 : connectingState(initialIntent, 0, 1, null),
251237 );
252 const session = yield* SubscriptionRef.make<Option.Option<RpcSession>>(Option.none());
238 const session = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(Option.none());
253239 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none());
254240
255241 const clearLease = Effect.all(
......@@ -280,7 +266,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")
280266 attempt: number,
281267 generation: number,
282268 lastFailure: ConnectionAttemptError | null,
283 progress: ConnectionDriverProgress,
269 progress: ConnectionDriver.ConnectionDriverProgress,
284270 ) {
285271 if ("prepared" in progress) {
286272 yield* SubscriptionRef.set(prepared, Option.some(progress.prepared));
......@@ -301,7 +287,11 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")
301287 });
302288
303289 const traceRelayEstablishment = (
304 effect: Effect.Effect<EnvironmentConnectionLease, ConnectionAttemptError, Scope.Scope>,
290 effect: Effect.Effect<
291 ConnectionDriver.EnvironmentConnectionLease,
292 ConnectionAttemptError,
293 Scope.Scope
294 >,
305295 attempt: number,
306296 generation: number,
307297 pendingRetry: Option.Option<PendingRetryTrace>,
......@@ -392,7 +382,9 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")
392382 }
393383 });
394384
395 const monitorConnectedLease = Effect.fnUntraced(function* (lease: EnvironmentConnectionLease) {
385 const monitorConnectedLease = Effect.fnUntraced(function* (
386 lease: ConnectionDriver.EnvironmentConnectionLease,
387 ) {
396388 for (;;) {
397389 const next = yield* Queue.take(signals);
398390 switch (next._tag) {
......@@ -417,7 +409,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")
417409 Effect.fail(
418410 new ConnectionTransientError({
419411 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.`,
421413 }),
422414 ),
423415 }),
......@@ -499,7 +491,7 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")
499491 failure: {
500492 error: new ConnectionTransientError({
501493 reason: "timeout",
502 message: `${target.label} did not respond during connection setup.`,
494 detail: `${target.label} did not respond during connection setup.`,
503495 }),
504496 attemptSpan: Option.none(),
505497 },
......@@ -722,3 +714,14 @@ export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")
722714 retryNow,
723715 });
724716});
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 @@
11import * as Context from "effect/Context";
2import * as Layer from "effect/Layer";
23import type * as Stream from "effect/Stream";
34
45export type ConnectionWakeup = "application-active" | "credentials-changed";
......@@ -9,3 +10,8 @@ export class ConnectionWakeups extends Context.Service<
910 readonly changes: Stream.Stream<ConnectionWakeup>;
1011 }
1112>()("@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 {
1818 PrimaryConnectionTarget,
1919 type PreparedConnection,
2020} from "../connection/model.ts";
21import {
22 EnvironmentSupervisor,
23 type EnvironmentSupervisorService,
24} from "../connection/supervisor.ts";
25import type { RpcSession } from "../rpc/session.ts";
21import * as EnvironmentSupervisor from "../connection/supervisor.ts";
22import * as RpcSession from "../rpc/session.ts";
2623import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2724import { archiveThread, createProject, stopThreadSession } from "./commands.ts";
2825
......@@ -51,14 +48,14 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
5148 return { sequence: dispatched.length };
5249 }),
5350 } as unknown as WsRpcProtocolClient;
54 const session: RpcSession = {
51 const session: RpcSession.RpcSession = {
5552 client,
5653 initialConfig: Effect.never,
5754 ready: Effect.void,
5855 probe: Effect.void,
5956 closed: Effect.never,
6057 };
61 return EnvironmentSupervisor.of({
58 return EnvironmentSupervisor.EnvironmentSupervisor.of({
6259 target: TARGET,
6360 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
6461 session: yield* SubscriptionRef.make(Option.some(session)),
......@@ -66,7 +63,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct
6663 connect: Effect.void,
6764 disconnect: Effect.void,
6865 retryNow: Effect.void,
69 } satisfies EnvironmentSupervisorService);
66 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
7067});
7168
7269describe("environment commands", () => {
......@@ -80,7 +77,7 @@ describe("environment commands", () => {
8077 title: "Project",
8178 workspaceRoot: "/workspace/project",
8279 createdAt: "2026-06-06T00:00:00.000Z",
83 }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor));
80 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
8481
8582 expect(result).toEqual({ sequence: 1 });
8683 expect(dispatched).toEqual([
......@@ -105,7 +102,7 @@ describe("environment commands", () => {
105102 commandId: CommandId.make("queued-command"),
106103 threadId: ThreadId.make("thread-1"),
107104 createdAt: "2026-06-06T00:01:00.000Z",
108 }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor));
105 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
109106
110107 expect(dispatched).toEqual([
111108 {
......@@ -126,7 +123,7 @@ describe("environment commands", () => {
126123 yield* archiveThread({
127124 commandId: CommandId.make("archive-command"),
128125 threadId: ThreadId.make("thread-1"),
129 }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor));
126 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
130127
131128 expect(dispatched).toEqual([
132129 {
packages/client-runtime/src/platform/storageDocument.test.ts+2-2
......@@ -1,7 +1,7 @@
11import { EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
33
4import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts";
4import * as TokenStore from "../authorization/tokenStore.ts";
55import {
66 BearerConnectionCredential,
77 BearerConnectionProfile,
......@@ -38,7 +38,7 @@ const BEARER_PROFILE = new BearerConnectionProfile({
3838const BEARER_CREDENTIAL = new BearerConnectionCredential({
3939 token: "bearer-token",
4040});
41const REMOTE_TOKEN = new RemoteDpopAccessToken({
41const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({
4242 environmentId: ENVIRONMENT_ID,
4343 label: "Remote",
4444 endpoint: {
packages/client-runtime/src/platform/storageDocument.ts+2-2
......@@ -6,7 +6,7 @@ import {
66 ConnectionProfile,
77} from "../connection/catalog.ts";
88import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts";
9import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts";
9import * as TokenStore from "../authorization/tokenStore.ts";
1010
1111export const StoredConnectionCredential = Schema.Struct({
1212 connectionId: Schema.String,
......@@ -19,7 +19,7 @@ export const ConnectionCatalogDocument = Schema.Struct({
1919 targets: Schema.Array(PersistedConnectionTarget),
2020 profiles: Schema.Array(ConnectionProfile),
2121 credentials: Schema.Array(StoredConnectionCredential),
22 remoteDpopTokens: Schema.Array(RemoteDpopAccessToken),
22 remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken),
2323});
2424export 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";
1313import * as Stream from "effect/Stream";
1414import * as SubscriptionRef from "effect/SubscriptionRef";
1515
16import {
17 ManagedRelayClient,
18 ManagedRelayClientError,
19 ManagedRelayRequestTimeoutError,
20 type ManagedRelayClientShape,
21} from "./managedRelay.ts";
22import { CloudSession } from "../platform/capabilities.ts";
23import { Connectivity } from "../connection/connectivity.ts";
16import * as ManagedRelay from "./managedRelay.ts";
17import * as ClientCapabilities from "../platform/capabilities.ts";
18import * as Connectivity from "../connection/connectivity.ts";
2419import { ConnectionBlockedError, type NetworkStatus } from "../connection/model.ts";
25import { ConnectionWakeups } from "../connection/wakeups.ts";
26import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts";
20import * as ConnectionWakeups from "../connection/wakeups.ts";
21import * as RelayEnvironmentDiscovery from "./discovery.ts";
2722
2823const environments = [
2924 {
......@@ -63,7 +58,7 @@ function status(
6358const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
6459 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online");
6560 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);
6762 const secondListCall = yield* Deferred.make<void>();
6863 const clerkToken = yield* Ref.make<string | null>("clerk-token");
6964 const wakeups = yield* SubscriptionRef.make<{
......@@ -74,10 +69,16 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
7469 reason: "application-active",
7570 });
7671 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 >(),
7876 );
7977 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 >();
8182 yield* Ref.update(statusRequests, (current) => {
8283 const next = new Map(current);
8384 next.set(environment.environmentId, request);
......@@ -85,7 +86,7 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
8586 });
8687 }
8788
88 const client = ManagedRelayClient.of({
89 const client = ManagedRelay.ManagedRelayClient.of({
8990 relayUrl: "https://relay.example.test",
9091 listEnvironments: () =>
9192 Effect.gen(function* () {
......@@ -112,25 +113,25 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
112113 unregisterDevice: () => Effect.die("unused"),
113114 registerLiveActivity: () => Effect.die("unused"),
114115 resetTokenCache: Effect.void,
115 } satisfies ManagedRelayClientShape);
116 const connectivity = Connectivity.of({
116 } satisfies ManagedRelay.ManagedRelayClient["Service"]);
117 const connectivity = Connectivity.Connectivity.of({
117118 status: SubscriptionRef.get(networkStatus),
118119 changes: SubscriptionRef.changes(networkStatus),
119120 });
120 const layer = relayEnvironmentDiscoveryLayer.pipe(
121 const layer = RelayEnvironmentDiscovery.layer.pipe(
121122 Layer.provide(
122123 Layer.mergeAll(
123 Layer.succeed(ManagedRelayClient, client),
124 Layer.succeed(ManagedRelay.ManagedRelayClient, client),
124125 Layer.succeed(
125 CloudSession,
126 CloudSession.of({
126 ClientCapabilities.CloudSession,
127 ClientCapabilities.CloudSession.of({
127128 clerkToken: Ref.get(clerkToken).pipe(
128129 Effect.flatMap((token) =>
129130 token === null
130131 ? Effect.fail(
131132 new ConnectionBlockedError({
132133 reason: "authentication",
133 message: "Signed out.",
134 detail: "Signed out.",
134135 }),
135136 )
136137 : Effect.succeed(token),
......@@ -138,10 +139,10 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () {
138139 ),
139140 }),
140141 ),
141 Layer.succeed(Connectivity, connectivity),
142 Layer.succeed(Connectivity.Connectivity, connectivity),
142143 Layer.succeed(
143 ConnectionWakeups,
144 ConnectionWakeups.of({
144 ConnectionWakeups.ConnectionWakeups,
145 ConnectionWakeups.ConnectionWakeups.of({
145146 changes: SubscriptionRef.changes(wakeups).pipe(
146147 Stream.drop(1),
147148 Stream.map((event) => event.reason),
......@@ -173,7 +174,7 @@ describe("RelayEnvironmentDiscovery", () => {
173174 Effect.gen(function* () {
174175 const harness = yield* makeHarness();
175176 yield* Effect.gen(function* () {
176 const discovery = yield* RelayEnvironmentDiscovery;
177 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
177178 const refreshFiber = yield* Effect.forkChild(discovery.refresh);
178179
179180 const checking = yield* SubscriptionRef.changes(discovery.state).pipe(
......@@ -224,7 +225,7 @@ describe("RelayEnvironmentDiscovery", () => {
224225 Effect.gen(function* () {
225226 const harness = yield* makeHarness();
226227 yield* Effect.gen(function* () {
227 const discovery = yield* RelayEnvironmentDiscovery;
228 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
228229 const requests = yield* Ref.get(harness.statusRequests);
229230 for (const environment of environments) {
230231 yield* Deferred.succeed(
......@@ -253,13 +254,13 @@ describe("RelayEnvironmentDiscovery", () => {
253254 it.effect("publishes listing failures without rejecting the refresh command", () =>
254255 Effect.gen(function* () {
255256 const networkStatus = yield* SubscriptionRef.make<NetworkStatus>("online");
256 const client = ManagedRelayClient.of({
257 const client = ManagedRelay.ManagedRelayClient.of({
257258 relayUrl: "https://relay.example.test",
258259 listEnvironments: () =>
259260 Effect.fail(
260 new ManagedRelayClientError({
261 new ManagedRelay.ManagedRelayClientError({
261262 message: "Relay environment listing timed out.",
262 cause: new ManagedRelayRequestTimeoutError({
263 cause: new ManagedRelay.ManagedRelayRequestTimeoutError({
263264 message: "Relay environment listing timed out.",
264265 }),
265266 }),
......@@ -274,25 +275,28 @@ describe("RelayEnvironmentDiscovery", () => {
274275 unregisterDevice: () => Effect.die("unused"),
275276 registerLiveActivity: () => Effect.die("unused"),
276277 resetTokenCache: Effect.void,
277 } satisfies ManagedRelayClientShape);
278 const layer = relayEnvironmentDiscoveryLayer.pipe(
278 } satisfies ManagedRelay.ManagedRelayClient["Service"]);
279 const layer = RelayEnvironmentDiscovery.layer.pipe(
279280 Layer.provide(
280281 Layer.mergeAll(
281 Layer.succeed(ManagedRelayClient, client),
282 Layer.succeed(CloudSession, {
282 Layer.succeed(ManagedRelay.ManagedRelayClient, client),
283 Layer.succeed(ClientCapabilities.CloudSession, {
283284 clerkToken: Effect.succeed("clerk-token"),
284285 }),
285 Layer.succeed(Connectivity, {
286 Layer.succeed(Connectivity.Connectivity, {
286287 status: SubscriptionRef.get(networkStatus),
287288 changes: SubscriptionRef.changes(networkStatus),
288289 }),
289 Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })),
290 Layer.succeed(
291 ConnectionWakeups.ConnectionWakeups,
292 ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }),
293 ),
290294 ),
291295 ),
292296 );
293297
294298 yield* Effect.gen(function* () {
295 const discovery = yield* RelayEnvironmentDiscovery;
299 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
296300 yield* discovery.refresh;
297301
298302 const state = yield* SubscriptionRef.get(discovery.state);
......@@ -310,7 +314,7 @@ describe("RelayEnvironmentDiscovery", () => {
310314 Effect.gen(function* () {
311315 const harness = yield* makeHarness();
312316 yield* Effect.gen(function* () {
313 const discovery = yield* RelayEnvironmentDiscovery;
317 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
314318 const requests = yield* Ref.get(harness.statusRequests);
315319 for (const environment of environments) {
316320 yield* Deferred.succeed(
......@@ -323,7 +327,7 @@ describe("RelayEnvironmentDiscovery", () => {
323327
324328 yield* Ref.set(
325329 harness.listFailure,
326 new ManagedRelayClientError({
330 new ManagedRelay.ManagedRelayClientError({
327331 message: "Relay environment listing failed.",
328332 }),
329333 );
......@@ -340,7 +344,7 @@ describe("RelayEnvironmentDiscovery", () => {
340344 Effect.gen(function* () {
341345 const harness = yield* makeHarness();
342346 yield* Effect.gen(function* () {
343 const discovery = yield* RelayEnvironmentDiscovery;
347 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
344348 const refreshFiber = yield* Effect.forkChild(discovery.refresh);
345349 yield* SubscriptionRef.changes(discovery.state).pipe(
346350 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";
1616import * as Stream from "effect/Stream";
1717import * as SubscriptionRef from "effect/SubscriptionRef";
1818
19import { ManagedRelayClient } from "./managedRelay.ts";
20import { CloudSession } from "../platform/capabilities.ts";
21import { Connectivity } from "../connection/connectivity.ts";
19import * as ManagedRelay from "./managedRelay.ts";
20import * as ClientCapabilities from "../platform/capabilities.ts";
21import * as Connectivity from "../connection/connectivity.ts";
2222import { mapManagedRelayError } from "../connection/errors.ts";
2323import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
24import { ConnectionWakeups } from "../connection/wakeups.ts";
24import * as ConnectionWakeups from "../connection/wakeups.ts";
2525
2626export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error";
2727
......@@ -39,14 +39,12 @@ export interface RelayEnvironmentDiscoveryState {
3939 readonly error: Option.Option<ConnectionAttemptError>;
4040}
4141
42export interface RelayEnvironmentDiscoveryService {
43 readonly state: SubscriptionRef.SubscriptionRef<RelayEnvironmentDiscoveryState>;
44 readonly refresh: Effect.Effect<void>;
45}
46
4742export class RelayEnvironmentDiscovery extends Context.Service<
4843 RelayEnvironmentDiscovery,
49 RelayEnvironmentDiscoveryService
44 {
45 readonly state: SubscriptionRef.SubscriptionRef<RelayEnvironmentDiscoveryState>;
46 readonly refresh: Effect.Effect<void>;
47 }
5048>()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {}
5149
5250export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = {
......@@ -64,7 +62,7 @@ function validateStatus(
6462 return Effect.fail(
6563 new ConnectionBlockedError({
6664 reason: "configuration",
67 message: "Relay returned status for a different environment.",
65 detail: "Relay returned status for a different environment.",
6866 }),
6967 );
7068 }
......@@ -76,7 +74,7 @@ function validateStatus(
7674 return Effect.fail(
7775 new ConnectionBlockedError({
7876 reason: "configuration",
79 message: "Relay returned status for a different environment endpoint.",
77 detail: "Relay returned status for a different environment endpoint.",
8078 }),
8179 );
8280 }
......@@ -87,7 +85,7 @@ function validateStatus(
8785 return Effect.fail(
8886 new ConnectionBlockedError({
8987 reason: "configuration",
90 message: "Relay returned a descriptor for a different environment.",
88 detail: "Relay returned a descriptor for a different environment.",
9189 }),
9290 );
9391 }
......@@ -104,11 +102,11 @@ function relayAccountId(clerkToken: string): Option.Option<string> {
104102 }
105103}
106104
107const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
108 const relay = yield* ManagedRelayClient;
109 const session = yield* CloudSession;
110 const connectivity = yield* Connectivity;
111 const wakeups = yield* ConnectionWakeups;
105export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
106 const relay = yield* ManagedRelay.ManagedRelayClient;
107 const session = yield* ClientCapabilities.CloudSession;
108 const connectivity = yield* Connectivity.Connectivity;
109 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
112110 const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
113111 const refreshLock = yield* Semaphore.make(1);
114112 const hasRefreshed = yield* Ref.make(false);
......@@ -327,7 +325,4 @@ const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make"
327325 return RelayEnvironmentDiscovery.of({ state, refresh });
328326});
329327
330export const relayEnvironmentDiscoveryLayer = Layer.effect(
331 RelayEnvironmentDiscovery,
332 makeRelayEnvironmentDiscovery(),
333);
328export const layer = Layer.effect(RelayEnvironmentDiscovery, make());
packages/client-runtime/src/rpc/client.test.ts+16-17
......@@ -22,11 +22,8 @@ import {
2222 type PreparedConnection,
2323 type SupervisorConnectionState,
2424} from "../connection/model.ts";
25import {
26 EnvironmentSupervisor,
27 type EnvironmentSupervisorService,
28} from "../connection/supervisor.ts";
29import type { RpcSession } from "../rpc/session.ts";
25import * as EnvironmentSupervisor from "../connection/supervisor.ts";
26import * as RpcSession from "../rpc/session.ts";
3027import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
3128import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts";
3229
......@@ -46,7 +43,7 @@ const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = {
4643 stage: "downloading",
4744};
4845
49function session(client: WsRpcProtocolClient): RpcSession {
46function session(client: WsRpcProtocolClient): RpcSession.RpcSession {
5047 return {
5148 client,
5249 initialConfig: Effect.never,
......@@ -58,10 +55,12 @@ function session(client: WsRpcProtocolClient): RpcSession {
5855
5956const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {
6057 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 );
6261 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none());
6362 const retryCount = yield* Ref.make(0);
64 const supervisor = EnvironmentSupervisor.of({
63 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
6564 target: TARGET,
6665 state,
6766 session: activeSession,
......@@ -69,7 +68,7 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {
6968 connect: Effect.void,
7069 disconnect: Effect.void,
7170 retryNow: Ref.update(retryCount, (count) => count + 1),
72 } satisfies EnvironmentSupervisorService);
71 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
7372 return {
7473 activeSession,
7574 retryCount,
......@@ -89,7 +88,7 @@ describe("environment RPC", () => {
8988 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
9089
9190 const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe(
92 Effect.provideService(EnvironmentSupervisor, supervisor),
91 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
9392 Effect.provideService(
9493 EnvironmentRpcRequestObserver,
9594 EnvironmentRpcRequestObserver.of({
......@@ -128,7 +127,7 @@ describe("environment RPC", () => {
128127 const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe(
129128 Stream.take(2),
130129 Stream.runCollect,
131 Effect.provideService(EnvironmentSupervisor, supervisor),
130 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
132131 Effect.forkChild,
133132 );
134133 yield* Effect.yieldNow;
......@@ -172,7 +171,7 @@ describe("environment RPC", () => {
172171
173172 const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe(
174173 Stream.runDrain,
175 Effect.provideService(EnvironmentSupervisor, supervisor),
174 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
176175 Effect.forkChild,
177176 );
178177 yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient)));
......@@ -212,7 +211,7 @@ describe("environment RPC", () => {
212211
213212 const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe(
214213 Stream.runDrain,
215 Effect.provideService(EnvironmentSupervisor, supervisor),
214 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
216215 Effect.forkChild,
217216 );
218217 yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient)));
......@@ -243,7 +242,7 @@ describe("environment RPC", () => {
243242 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
244243 const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe(
245244 Stream.runDrain,
246 Effect.provideService(EnvironmentSupervisor, supervisor),
245 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
247246 Effect.flip,
248247 );
249248
......@@ -283,7 +282,7 @@ describe("environment RPC", () => {
283282 },
284283 ).pipe(
285284 Stream.runDrain,
286 Effect.provideService(EnvironmentSupervisor, supervisor),
285 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
287286 Effect.forkChild,
288287 );
289288 for (let attempt = 0; attempt < 100 && observedFailures.length < 1; attempt += 1) {
......@@ -329,7 +328,7 @@ describe("environment RPC", () => {
329328 },
330329 ).pipe(
331330 Stream.runDrain,
332 Effect.provideService(EnvironmentSupervisor, supervisor),
331 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
333332 Effect.forkChild,
334333 );
335334 for (let attempt = 0; attempt < 100; attempt += 1) {
......@@ -377,7 +376,7 @@ describe("environment RPC", () => {
377376 },
378377 ).pipe(
379378 Stream.runDrain,
380 Effect.provideService(EnvironmentSupervisor, supervisor),
379 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
381380 Effect.exit,
382381 );
383382
packages/client-runtime/src/rpc/index.ts+1-1
......@@ -1,4 +1,4 @@
11export * from "./client.ts";
22export * from "./http.ts";
33export * 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 {
1818 PrimaryConnectionTarget,
1919 type PreparedConnection,
2020} from "../connection/model.ts";
21import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts";
21import * as RpcSession from "./session.ts";
2222
2323type SocketEventType = "open" | "message" | "close" | "error";
2424type SocketEvent = {
......@@ -149,8 +149,8 @@ const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () {
149149 sockets.push(socket);
150150 return socket as unknown as globalThis.WebSocket;
151151 });
152 const layer = rpcSessionFactoryLayer.pipe(Layer.provide(constructorLayer));
153 const factory = yield* RpcSessionFactory.pipe(Effect.provide(layer));
152 const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer));
153 const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer));
154154 return { factory, sockets };
155155});
156156
packages/client-runtime/src/rpc/session.ts+74-74
......@@ -5,7 +5,8 @@ import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Schedule from "effect/Schedule";
77import 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";
910import * as Socket from "effect/unstable/socket/Socket";
1011
1112import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts";
......@@ -47,98 +48,97 @@ function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptErro
4748 case "EnvironmentAuthorizationError":
4849 return new ConnectionBlockedError({
4950 reason: "permission",
50 message: error.message,
51 detail: error.message,
5152 });
5253 case "KeybindingsConfigParseError":
5354 case "ServerSettingsError":
5455 return new ConnectionTransientErrorClass({
5556 reason: "remote-unavailable",
56 message: error.message,
57 detail: error.message,
5758 });
5859 case "RpcClientError":
5960 return new ConnectionTransientErrorClass({
6061 reason: "transport",
61 message: error.message,
62 detail: error.message,
6263 });
6364 }
6465}
6566
66export const rpcSessionFactoryLayer = Layer.effect(
67 RpcSessionFactory,
68 Effect.gen(function* () {
69 const webSocketConstructor = yield* Socket.WebSocketConstructor;
67export const make = Effect.gen(function* () {
68 const webSocketConstructor = yield* Socket.WebSocketConstructor;
7069
71 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {
72 yield* Effect.annotateCurrentSpan({
73 "connection.environment.id": connection.environmentId,
74 });
70 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {
71 yield* Effect.annotateCurrentSpan({
72 "connection.environment.id": connection.environmentId,
73 });
7574
76 const connected = yield* Deferred.make<void>();
77 const disconnected = yield* Deferred.make<never, ConnectionTransientError>();
78 const hooks = RpcClient.ConnectionHooks.of({
79 onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid),
80 onDisconnect: Deferred.isDone(connected).pipe(
81 Effect.flatMap((wasConnected) =>
82 Deferred.fail(
83 disconnected,
84 new ConnectionTransientErrorClass({
85 reason: "transport",
86 message: wasConnected
87 ? `${connection.label} disconnected.`
88 : `${connection.label} could not establish a WebSocket connection.`,
89 }),
90 ),
91 ),
92 Effect.asVoid,
93 ),
94 });
95 const socketLayer = Socket.layerWebSocket(connection.socketUrl, {
96 openTimeout: SOCKET_OPEN_TIMEOUT,
97 }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)));
98 const protocolLayer = Layer.effect(
99 RpcClient.Protocol,
100 RpcClient.makeProtocolSocket({
101 retryTransientErrors: false,
102 retryPolicy: Schedule.recurs(0),
103 }),
104 ).pipe(
105 Layer.provide(
106 Layer.mergeAll(
107 socketLayer,
108 RpcSerialization.layerJson,
109 Layer.succeed(RpcClient.ConnectionHooks, hooks),
75 const connected = yield* Deferred.make<void>();
76 const disconnected = yield* Deferred.make<never, ConnectionTransientError>();
77 const hooks = RpcClient.ConnectionHooks.of({
78 onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid),
79 onDisconnect: Deferred.isDone(connected).pipe(
80 Effect.flatMap((wasConnected) =>
81 Deferred.fail(
82 disconnected,
83 new ConnectionTransientErrorClass({
84 reason: "transport",
85 detail: wasConnected
86 ? `${connection.label} disconnected.`
87 : `${connection.label} could not establish a WebSocket connection.`,
88 }),
11089 ),
11190 ),
112 );
113 const protocolContext = yield* Layer.build(protocolLayer).pipe(
114 Effect.withSpan("environment.websocket.connect"),
115 );
116 const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext));
117 const initialConfig = yield* Effect.cached(
118 client[WS_METHODS.serverGetConfig]({}).pipe(
119 Effect.mapError(mapInitialConfigError),
120 Effect.withSpan("environment.initialSync"),
91 Effect.asVoid,
92 ),
93 });
94 const socketLayer = Socket.layerWebSocket(connection.socketUrl, {
95 openTimeout: SOCKET_OPEN_TIMEOUT,
96 }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)));
97 const protocolLayer = Layer.effect(
98 RpcClient.Protocol,
99 RpcClient.makeProtocolSocket({
100 retryTransientErrors: false,
101 retryPolicy: Schedule.recurs(0),
102 }),
103 ).pipe(
104 Layer.provide(
105 Layer.mergeAll(
106 socketLayer,
107 RpcSerialization.layerJson,
108 Layer.succeed(RpcClient.ConnectionHooks, hooks),
121109 ),
122 );
123 const probe = client[WS_METHODS.serverGetConfig]({}).pipe(
110 ),
111 );
112 const protocolContext = yield* Layer.build(protocolLayer).pipe(
113 Effect.withSpan("environment.websocket.connect"),
114 );
115 const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext));
116 const initialConfig = yield* Effect.cached(
117 client[WS_METHODS.serverGetConfig]({}).pipe(
124118 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),
125133 Effect.asVoid,
126 Effect.withSpan("clientRuntime.connection.rpcSession.probe"),
127 );
134 Effect.raceFirst(Deferred.await(disconnected)),
135 ),
136 probe,
137 closed: Deferred.await(disconnected),
138 } satisfies RpcSession;
139 });
128140
129 return {
130 client,
131 initialConfig,
132 ready: Deferred.await(connected).pipe(
133 Effect.andThen(initialConfig),
134 Effect.asVoid,
135 Effect.raceFirst(Deferred.await(disconnected)),
136 ),
137 probe,
138 closed: Deferred.await(disconnected),
139 } satisfies RpcSession;
140 });
141 return RpcSessionFactory.of({ connect });
142});
141143
142 return RpcSessionFactory.of({ connect });
143 }),
144);
144export const layer = Layer.effect(RpcSessionFactory, make);
packages/client-runtime/src/state/connections.ts+21-11
......@@ -5,10 +5,10 @@ import * as Stream from "effect/Stream";
55import * as SubscriptionRef from "effect/SubscriptionRef";
66import { AsyncResult, Atom } from "effect/unstable/reactivity";
77
8import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts";
8import * as EnvironmentRegistry from "../connection/registry.ts";
99import type { ConnectionCatalogEntry } from "../connection/catalog.ts";
1010import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts";
11import { EnvironmentSupervisor } from "../connection/supervisor.ts";
11import * as EnvironmentSupervisor from "../connection/supervisor.ts";
1212import {
1313 createAtomCommandScheduler,
1414 createRuntimeCommand,
......@@ -26,13 +26,13 @@ export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.f
2626});
2727
2828export function createEnvironmentCatalogAtoms<R, E>(
29 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
29 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
3030) {
3131 const commandScheduler = createAtomCommandScheduler();
3232 const serial = { mode: "serial" as const, key: () => "environment-catalog" };
3333 const catalogAtom = runtime.atom(
3434 Stream.unwrap(
35 EnvironmentRegistry.pipe(
35 EnvironmentRegistry.EnvironmentRegistry.pipe(
3636 Effect.map((registry) =>
3737 SubscriptionRef.changes(registry.entries).pipe(
3838 Stream.map((entries) => ({
......@@ -52,7 +52,7 @@ export function createEnvironmentCatalogAtoms<R, E>(
5252
5353 const networkStatusAtom = runtime.atom(
5454 Stream.unwrap(
55 EnvironmentRegistry.pipe(
55 EnvironmentRegistry.EnvironmentRegistry.pipe(
5656 Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)),
5757 ),
5858 ),
......@@ -68,7 +68,7 @@ export function createEnvironmentCatalogAtoms<R, E>(
6868 followStreamInEnvironment(
6969 environmentId,
7070 Stream.unwrap(
71 EnvironmentSupervisor.pipe(
71 EnvironmentSupervisor.EnvironmentSupervisor.pipe(
7272 Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)),
7373 ),
7474 ),
......@@ -81,29 +81,39 @@ export function createEnvironmentCatalogAtoms<R, E>(
8181 label: "environment-catalog:register",
8282 scheduler: commandScheduler,
8383 concurrency: serial,
84 execute: (target: Parameters<EnvironmentRegistryService["register"]>[0]) =>
85 EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.register(target))),
84 execute: (
85 target: Parameters<EnvironmentRegistry.EnvironmentRegistry["Service"]["register"]>[0],
86 ) =>
87 EnvironmentRegistry.EnvironmentRegistry.pipe(
88 Effect.flatMap((registry) => registry.register(target)),
89 ),
8690 });
8791 const remove = createRuntimeCommand(runtime, {
8892 label: "environment-catalog:remove",
8993 scheduler: commandScheduler,
9094 concurrency: serial,
9195 execute: (environmentId: EnvironmentIdType) =>
92 EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.remove(environmentId))),
96 EnvironmentRegistry.EnvironmentRegistry.pipe(
97 Effect.flatMap((registry) => registry.remove(environmentId)),
98 ),
9399 });
94100 const removeRelayEnvironments = createRuntimeCommand(runtime, {
95101 label: "environment-catalog:remove-relay-environments",
96102 scheduler: commandScheduler,
97103 concurrency: serial,
98104 execute: (_input: void) =>
99 EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.removeRelayEnvironments())),
105 EnvironmentRegistry.EnvironmentRegistry.pipe(
106 Effect.flatMap((registry) => registry.removeRelayEnvironments()),
107 ),
100108 });
101109 const retryNow = createRuntimeCommand(runtime, {
102110 label: "environment-catalog:retry-now",
103111 scheduler: commandScheduler,
104112 concurrency: serial,
105113 execute: (environmentId: EnvironmentIdType) =>
106 EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.retryNow(environmentId))),
114 EnvironmentRegistry.EnvironmentRegistry.pipe(
115 Effect.flatMap((registry) => registry.retryNow(environmentId)),
116 ),
107117 });
108118
109119 return {
packages/client-runtime/src/state/shell-sync.test.ts+10-13
......@@ -16,12 +16,9 @@ import {
1616 PrimaryConnectionTarget,
1717 type PreparedConnection,
1818} from "../connection/model.ts";
19import {
20 EnvironmentSupervisor,
21 type EnvironmentSupervisorService,
22} from "../connection/supervisor.ts";
23import { EnvironmentCacheStore } from "../platform/persistence.ts";
24import type { RpcSession } from "../rpc/session.ts";
19import * as EnvironmentSupervisor from "../connection/supervisor.ts";
20import * as Persistence from "../platform/persistence.ts";
21import * as RpcSession from "../rpc/session.ts";
2522import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2623import { makeEnvironmentShellState } from "./shell.ts";
2724
......@@ -39,7 +36,7 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {
3936 updatedAt: "2026-06-06T00:00:00.000Z",
4037};
4138
42function session(client: WsRpcProtocolClient): RpcSession {
39function session(client: WsRpcProtocolClient): RpcSession.RpcSession {
4340 return {
4441 client,
4542 initialConfig: Effect.never,
......@@ -57,10 +54,10 @@ describe("environment shell synchronization", () => {
5754 [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
5855 } as unknown as WsRpcProtocolClient;
5956 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>>(
6158 Option.some(session(client)),
6259 );
63 const supervisor = EnvironmentSupervisor.of({
60 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
6461 target: TARGET,
6562 state: supervisorState,
6663 session: activeSession,
......@@ -68,8 +65,8 @@ describe("environment shell synchronization", () => {
6865 connect: Effect.void,
6966 disconnect: Effect.void,
7067 retryNow: Effect.void,
71 } satisfies EnvironmentSupervisorService);
72 const cache = EnvironmentCacheStore.of({
68 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
69 const cache = Persistence.EnvironmentCacheStore.of({
7370 loadShell: () => Effect.succeed(Option.none()),
7471 saveShell: () => Effect.never,
7572 loadThread: () => Effect.succeed(Option.none()),
......@@ -78,8 +75,8 @@ describe("environment shell synchronization", () => {
7875 clear: () => Effect.void,
7976 });
8077 const shellState = yield* makeEnvironmentShellState().pipe(
81 Effect.provideService(EnvironmentSupervisor, supervisor),
82 Effect.provideService(EnvironmentCacheStore, cache),
78 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
79 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
8380 );
8481
8582 yield* SubscriptionRef.set(supervisorState, {
packages/client-runtime/src/state/threads-sync.test.ts+10-13
......@@ -24,12 +24,9 @@ import {
2424 type PreparedConnection,
2525 type SupervisorConnectionState,
2626} from "../connection/model.ts";
27import {
28 EnvironmentSupervisor,
29 type EnvironmentSupervisorService,
30} from "../connection/supervisor.ts";
31import { EnvironmentCacheStore } from "../platform/persistence.ts";
32import type { RpcSession } from "../rpc/session.ts";
27import * as EnvironmentSupervisor from "../connection/supervisor.ts";
28import * as Persistence from "../platform/persistence.ts";
29import * as RpcSession from "../rpc/session.ts";
3330import {
3431 EMPTY_ENVIRONMENT_THREAD_STATE,
3532 makeEnvironmentThreadState,
......@@ -69,7 +66,7 @@ const BASE_THREAD: OrchestrationThread = {
6966
7067type TestThreadInput = OrchestrationThreadStreamItem | Error;
7168
72function testSession(client: WsRpcProtocolClient): RpcSession {
69function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession {
7370 return {
7471 client,
7572 initialConfig: Effect.never,
......@@ -117,11 +114,11 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
117114 ),
118115 ),
119116 } as unknown as WsRpcProtocolClient;
120 const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession>>(
117 const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
121118 Option.some(testSession(client)),
122119 );
123120 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none());
124 const supervisor = EnvironmentSupervisor.of({
121 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
125122 target: TARGET,
126123 state: supervisorState,
127124 session: supervisorSession,
......@@ -129,8 +126,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
129126 connect: Effect.void,
130127 disconnect: Effect.void,
131128 retryNow: Ref.update(retryCount, (count) => count + 1),
132 } satisfies EnvironmentSupervisorService);
133 const cache = EnvironmentCacheStore.of({
129 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
130 const cache = Persistence.EnvironmentCacheStore.of({
134131 loadShell: () => Effect.succeed(Option.none()),
135132 saveShell: () => Effect.void,
136133 loadThread: (_environmentId, threadId) =>
......@@ -146,8 +143,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
146143 clear: () => Effect.void,
147144 });
148145 const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
149 Effect.provideService(EnvironmentSupervisor, supervisor),
150 Effect.provideService(EnvironmentCacheStore, cache),
146 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
147 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
151148 );
152149 yield* SubscriptionRef.changes(threadState).pipe(
153150 Stream.runForEach((state) =>