1import type { EnvironmentRpcSubscriptionObservation } from "@t3tools/client-runtime/rpc";
2import { type BackgroundScope, type EnvironmentId, WS_METHODS } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4
5interface RetainedScope {
6 readonly environmentId: EnvironmentId;
7 readonly scope: BackgroundScope;
8 refCount: number;
9}
10
11const retainedScopes = new Map<string, RetainedScope>();
12const listeners = new Set<() => void>();
13
14function notify(): void {
15 for (const listener of listeners) {
16 try {
17 listener();
18 } catch {
19 // A failing observer must not corrupt retained-scope lifetime.
20 }
21 }
22}
23
24function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string {
25 switch (scope.type) {
26 case "server-config":
27 case "diagnostics":
28 return JSON.stringify([environmentId, scope.type]);
29 case "provider-status":
30 return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]);
31 case "vcs-status":
32 case "git-refs":
33 return JSON.stringify([environmentId, scope.type, scope.cwd]);
34 case "thread":
35 return JSON.stringify([environmentId, scope.type, scope.threadId]);
36 }
37}
38
39function scopeForSubscription(
40 observation: EnvironmentRpcSubscriptionObservation,
41): BackgroundScope | null {
42 if (observation.method === WS_METHODS.subscribeResourceTelemetry) {
43 return { type: "diagnostics" };
44 }
45 if (observation.method !== WS_METHODS.subscribeVcsStatus) {
46 return null;
47 }
48 const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown };
49 if (input.includeRemote === false) return null;
50 return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null;
51}
52
53export function retainedMobileBackgroundScopes(
54 environmentId: EnvironmentId,
55): ReadonlyArray<BackgroundScope> {
56 return Array.from(retainedScopes.values(), (entry) =>
57 entry.environmentId === environmentId ? entry.scope : null,
58 ).filter((scope): scope is BackgroundScope => scope !== null);
59}
60
61export function observeMobileBackgroundActivitySubscription(
62 observation: EnvironmentRpcSubscriptionObservation,
63): Effect.Effect<Effect.Effect<void>> {
64 const scope = scopeForSubscription(observation);
65 if (scope === null) return Effect.succeed(Effect.void);
66 return Effect.sync(() => {
67 const environmentId = observation.environmentId as EnvironmentId;
68 const key = stableScopeKey(environmentId, scope);
69 const current = retainedScopes.get(key);
70 if (current) {
71 current.refCount += 1;
72 } else {
73 retainedScopes.set(key, { environmentId, scope, refCount: 1 });
74 notify();
75 }
76 return Effect.sync(() => {
77 const retained = retainedScopes.get(key);
78 if (!retained) return;
79 retained.refCount -= 1;
80 if (retained.refCount <= 0) {
81 retainedScopes.delete(key);
82 notify();
83 }
84 });
85 });
86}
87
88export function onRetainedMobileBackgroundScopesChange(listener: () => void): () => void {
89 listeners.add(listener);
90 return () => {
91 listeners.delete(listener);
92 };
93}