1import type {
2 RelayClientEnvironmentRecord,
3 RelayEnvironmentStatusResponse,
4} from "@t3tools/contracts/relay";
5import { decodeRelayJwt } from "@t3tools/shared/relayJwt";
6import {
7 RelayEnvironmentConnectScope,
8 RelayEnvironmentStatusScope,
9} from "@t3tools/contracts/relay";
10import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Ref from "effect/Ref";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18
19import * as ManagedRelay from "./managedRelay.ts";
20import * as ClientCapabilities from "../platform/capabilities.ts";
21import * as Connectivity from "../connection/connectivity.ts";
22import { mapManagedRelayError } from "../connection/errors.ts";
23import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";
24import * as ConnectionWakeups from "../connection/wakeups.ts";
25
26export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error";
27
28export interface RelayDiscoveredEnvironment {
29 readonly environment: RelayClientEnvironmentRecord;
30 readonly availability: RelayEnvironmentAvailability;
31 readonly status: Option.Option<RelayEnvironmentStatusResponse>;
32 readonly error: Option.Option<ConnectionAttemptError>;
33}
34
35export interface RelayEnvironmentDiscoveryState {
36 readonly environments: ReadonlyMap<string, RelayDiscoveredEnvironment>;
37 readonly refreshing: boolean;
38 readonly offline: boolean;
39 readonly error: Option.Option<ConnectionAttemptError>;
40}
41
42export class RelayEnvironmentDiscovery extends Context.Service<
43 RelayEnvironmentDiscovery,
44 {
45 readonly state: SubscriptionRef.SubscriptionRef<RelayEnvironmentDiscoveryState>;
46 readonly refresh: Effect.Effect<void>;
47 }
48>()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {}
49
50export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = {
51 environments: new Map(),
52 refreshing: false,
53 offline: false,
54 error: Option.none(),
55};
56
57function validateStatus(
58 environment: RelayClientEnvironmentRecord,
59 status: RelayEnvironmentStatusResponse,
60): Effect.Effect<RelayEnvironmentStatusResponse, ConnectionAttemptError> {
61 if (status.environmentId !== environment.environmentId) {
62 return Effect.fail(
63 new ConnectionBlockedError({
64 reason: "configuration",
65 detail: "Relay returned status for a different environment.",
66 }),
67 );
68 }
69 if (
70 status.endpoint.httpBaseUrl !== environment.endpoint.httpBaseUrl ||
71 status.endpoint.wsBaseUrl !== environment.endpoint.wsBaseUrl ||
72 status.endpoint.providerKind !== environment.endpoint.providerKind
73 ) {
74 return Effect.fail(
75 new ConnectionBlockedError({
76 reason: "configuration",
77 detail: "Relay returned status for a different environment endpoint.",
78 }),
79 );
80 }
81 if (
82 status.descriptor !== undefined &&
83 status.descriptor.environmentId !== environment.environmentId
84 ) {
85 return Effect.fail(
86 new ConnectionBlockedError({
87 reason: "configuration",
88 detail: "Relay returned a descriptor for a different environment.",
89 }),
90 );
91 }
92 return Effect.succeed(status);
93}
94
95function relayAccountId(clerkToken: string): Option.Option<string> {
96 try {
97 return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe(
98 Option.filter((subject) => subject.length > 0),
99 );
100 } catch {
101 return Option.none();
102 }
103}
104
105/** @public Service construction is part of the canonical Effect module API. */
106export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
107 const relay = yield* ManagedRelay.ManagedRelayClient;
108 const session = yield* ClientCapabilities.CloudSession;
109 const connectivity = yield* Connectivity.Connectivity;
110 const wakeups = yield* ConnectionWakeups.ConnectionWakeups;
111 const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
112 const refreshLock = yield* Semaphore.make(1);
113 const hasRefreshed = yield* Ref.make(false);
114 const accountGeneration = yield* Ref.make(0);
115 const activeAccountId = yield* Ref.make<Option.Option<string>>(Option.none());
116 const refreshGeneration = yield* Ref.make(0);
117 const offlineReportFingerprints = yield* Ref.make<ReadonlyMap<string, string>>(new Map());
118
119 const clearOfflineReport = Effect.fn("RelayEnvironmentDiscovery.clearOfflineReport")(function* (
120 environmentId: string,
121 ) {
122 yield* Ref.update(offlineReportFingerprints, (current) => {
123 if (!current.has(environmentId)) {
124 return current;
125 }
126 const next = new Map(current);
127 next.delete(environmentId);
128 return next;
129 });
130 });
131
132 const updateEnvironment = Effect.fn("RelayEnvironmentDiscovery.updateEnvironment")(function* (
133 generation: number,
134 environmentId: string,
135 update: (current: RelayDiscoveredEnvironment) => RelayDiscoveredEnvironment,
136 ) {
137 if ((yield* Ref.get(accountGeneration)) !== generation) {
138 return;
139 }
140 yield* SubscriptionRef.update(state, (current) => {
141 const entry = current.environments.get(environmentId);
142 if (entry === undefined) {
143 return current;
144 }
145 const environments = new Map(current.environments);
146 environments.set(environmentId, update(entry));
147 return { ...current, environments };
148 });
149 });
150
151 const refreshStatus = Effect.fn("RelayEnvironmentDiscovery.refreshStatus")(function* (
152 generation: number,
153 clerkToken: string,
154 environment: RelayClientEnvironmentRecord,
155 ) {
156 const result = yield* relay
157 .getEnvironmentStatus({
158 clerkToken,
159 scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope],
160 environmentId: environment.environmentId,
161 })
162 .pipe(
163 Effect.mapError(mapManagedRelayError),
164 Effect.flatMap((status) => validateStatus(environment, status)),
165 Effect.result,
166 );
167
168 if (result._tag === "Success") {
169 if (result.success.status === "offline") {
170 const fingerprint = `${result.success.endpoint.httpBaseUrl}\n${result.success.error ?? ""}`;
171 const shouldReport = yield* Ref.modify(offlineReportFingerprints, (current) => {
172 if (current.get(environment.environmentId) === fingerprint) {
173 return [false, current];
174 }
175 return [true, new Map(current).set(environment.environmentId, fingerprint)];
176 });
177 if (shouldReport) {
178 yield* Effect.logWarning("Relay environment health check reported offline", {
179 environmentId: result.success.environmentId,
180 endpoint: result.success.endpoint.httpBaseUrl,
181 message: result.success.error,
182 traceId: result.success.traceId,
183 });
184 }
185 } else {
186 yield* clearOfflineReport(environment.environmentId);
187 }
188 yield* updateEnvironment(generation, environment.environmentId, (current) => ({
189 ...current,
190 availability: result.success.status,
191 status: Option.some(result.success),
192 error: Option.none(),
193 }));
194 return;
195 }
196
197 yield* clearOfflineReport(environment.environmentId);
198 yield* updateEnvironment(generation, environment.environmentId, (current) => ({
199 ...current,
200 availability: "error",
201 error: Option.some(result.failure),
202 }));
203 });
204
205 const refresh = refreshLock.withPermits(1)(
206 Effect.gen(function* () {
207 yield* Ref.set(hasRefreshed, true);
208 if ((yield* connectivity.status) === "offline") {
209 yield* SubscriptionRef.update(state, (current) => ({
210 ...current,
211 refreshing: false,
212 offline: true,
213 }));
214 return;
215 }
216
217 let generation = yield* Ref.get(accountGeneration);
218 yield* Ref.set(refreshGeneration, generation);
219 yield* SubscriptionRef.set(state, {
220 environments: new Map(),
221 refreshing: true,
222 offline: false,
223 error: Option.none(),
224 });
225
226 // Signed out is the idle state, not a failure: the proactive refresh on
227 // credentials-changed also runs on sign-out and must settle back to a
228 // clean empty list. Only the session-level "no credentials" error is
229 // benign — relay-side auth failures (expired/invalid tokens) happen
230 // after this point and must surface as errors.
231 const tokenResult = yield* Effect.result(session.clerkToken);
232 if (tokenResult._tag === "Failure") {
233 const failure = tokenResult.failure;
234 if (failure._tag === "ConnectionBlockedError" && failure.reason === "authentication") {
235 if ((yield* Ref.get(accountGeneration)) !== generation) {
236 return;
237 }
238 yield* SubscriptionRef.update(state, (current) => ({
239 ...current,
240 refreshing: false,
241 }));
242 return;
243 }
244 return yield* failure;
245 }
246 const clerkToken = tokenResult.success;
247 if ((yield* Ref.get(accountGeneration)) !== generation) {
248 return;
249 }
250 const accountId = relayAccountId(clerkToken);
251 const previousAccountId = yield* Ref.get(activeAccountId);
252 if (
253 Option.isSome(previousAccountId) &&
254 (!Option.isSome(accountId) || previousAccountId.value !== accountId.value)
255 ) {
256 generation = yield* Ref.updateAndGet(accountGeneration, (current) => current + 1);
257 yield* Ref.set(refreshGeneration, generation);
258 }
259 yield* Ref.set(activeAccountId, accountId);
260
261 const environments = yield* relay
262 .listEnvironments({ clerkToken })
263 .pipe(Effect.mapError(mapManagedRelayError));
264 if ((yield* Ref.get(accountGeneration)) !== generation) {
265 return;
266 }
267 const next = new Map<string, RelayDiscoveredEnvironment>();
268 for (const environment of environments) {
269 next.set(environment.environmentId, {
270 environment,
271 availability: "checking",
272 status: Option.none(),
273 error: Option.none(),
274 });
275 }
276 yield* SubscriptionRef.update(state, (current) => ({
277 ...current,
278 environments: next,
279 }));
280
281 yield* Effect.forEach(
282 environments,
283 (environment) => refreshStatus(generation, clerkToken, environment),
284 {
285 concurrency: "unbounded",
286 discard: true,
287 },
288 );
289 if ((yield* Ref.get(accountGeneration)) !== generation) {
290 return;
291 }
292 yield* SubscriptionRef.update(state, (current) => ({
293 ...current,
294 refreshing: false,
295 }));
296 }).pipe(
297 Effect.catch((error) =>
298 Effect.gen(function* () {
299 const generation = yield* Ref.get(refreshGeneration);
300 if ((yield* Ref.get(accountGeneration)) !== generation) {
301 return;
302 }
303 yield* SubscriptionRef.update(state, (current) => ({
304 ...current,
305 refreshing: false,
306 error: Option.some(error),
307 }));
308 }),
309 ),
310 ),
311 );
312
313 yield* connectivity.changes.pipe(
314 Stream.changes,
315 Stream.runForEach((networkStatus) =>
316 networkStatus === "offline"
317 ? SubscriptionRef.update(state, (current) => ({
318 ...current,
319 refreshing: false,
320 offline: true,
321 }))
322 : Ref.get(hasRefreshed).pipe(
323 Effect.flatMap((shouldRefresh) => (shouldRefresh ? refresh : Effect.void)),
324 ),
325 ),
326 Effect.forkScoped,
327 );
328 yield* wakeups.changes.pipe(
329 Stream.runForEach((reason) =>
330 reason === "credentials-changed"
331 ? Effect.gen(function* () {
332 yield* Ref.update(accountGeneration, (current) => current + 1);
333 yield* Ref.set(activeAccountId, Option.none());
334 yield* Ref.set(offlineReportFingerprints, new Map());
335 yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
336 // Refresh proactively — this wakeup fires when a session activates
337 // (sign-in or cold start), and the list should be populated before
338 // any screen asks for it. A signed-out refresh settles back to the
339 // clean empty state.
340 yield* refresh.pipe(Effect.forkScoped);
341 })
342 : Effect.void,
343 ),
344 Effect.forkScoped,
345 );
346
347 return RelayEnvironmentDiscovery.of({ state, refresh });
348});
349
350export const layer = Layer.effect(RelayEnvironmentDiscovery, make());