1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2import {
3 EnvironmentRpcSubscriptionObserver,
4 request,
5 type EnvironmentRpcSubscriptionObservation,
6} from "@t3tools/client-runtime/rpc";
7import {
8 type BackgroundScope,
9 type ClientActivityReportInput,
10 type EnvironmentId,
11 WS_METHODS,
12} from "@t3tools/contracts";
13import * as Clock from "effect/Clock";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Queue from "effect/Queue";
18import * as Schedule from "effect/Schedule";
19import * as Stream from "effect/Stream";
20import * as SubscriptionRef from "effect/SubscriptionRef";
21
22import { randomUUID } from "./utils";
23
24const CLIENT_ID_STORAGE_KEY = "t3.backgroundActivity.clientId";
25const REPORT_INTERVAL_MS = 25_000;
26const LEASE_TTL_MS = 45_000;
27const RECENT_INTERACTION_WINDOW_MS = LEASE_TTL_MS;
28const BASELINE_SCOPES: ReadonlyArray<BackgroundScope> = [{ type: "provider-status" }];
29
30interface RetainedScope {
31 readonly environmentId: EnvironmentId;
32 readonly scope: BackgroundScope;
33 refCount: number;
34}
35
36const retainedScopes = new Map<string, RetainedScope>();
37const retainedScopeListeners = new Set<() => void>();
38
39function notifyRetainedScopesChanged(): void {
40 for (const listener of retainedScopeListeners) {
41 try {
42 listener();
43 } catch {
44 // A failing observer must not corrupt retained-scope lifetime.
45 }
46 }
47}
48
49function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string {
50 switch (scope.type) {
51 case "server-config":
52 case "diagnostics":
53 return JSON.stringify([environmentId, scope.type]);
54 case "provider-status":
55 return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]);
56 case "vcs-status":
57 case "git-refs":
58 return JSON.stringify([environmentId, scope.type, scope.cwd]);
59 case "thread":
60 return JSON.stringify([environmentId, scope.type, scope.threadId]);
61 }
62}
63
64function getClientId(): string {
65 try {
66 const existing = window.localStorage.getItem(CLIENT_ID_STORAGE_KEY);
67 if (existing) return existing;
68 const next = randomUUID();
69 window.localStorage.setItem(CLIENT_ID_STORAGE_KEY, next);
70 return next;
71 } catch {
72 return "ephemeral-browser-client";
73 }
74}
75
76function resolveClientKind(): ClientActivityReportInput["clientKind"] {
77 return window.desktopBridge ? "desktop-renderer" : "web";
78}
79
80export function wasRecentlyInteracted(lastInteractionAtMs: number, observedAtMs: number): boolean {
81 return (
82 lastInteractionAtMs <= observedAtMs &&
83 observedAtMs - lastInteractionAtMs <= RECENT_INTERACTION_WINDOW_MS
84 );
85}
86
87function createActivityReport(
88 environmentId: EnvironmentId,
89 lastInteractionAtMs: number,
90 observedAtMs: number,
91): ClientActivityReportInput {
92 const scopes = [...BASELINE_SCOPES];
93 for (const entry of retainedScopes.values()) {
94 if (entry.environmentId === environmentId) {
95 scopes.push(entry.scope);
96 }
97 }
98 return {
99 environmentId,
100 clientId: getClientId(),
101 clientKind: resolveClientKind(),
102 visible: document.visibilityState === "visible",
103 focused: document.hasFocus(),
104 recentlyInteracted: wasRecentlyInteracted(lastInteractionAtMs, observedAtMs),
105 appState: document.visibilityState === "visible" ? "active" : "background",
106 scopes,
107 ttlMs: LEASE_TTL_MS,
108 observedAt: DateTime.makeUnsafe(observedAtMs),
109 };
110}
111
112function scopeForSubscription(
113 observation: EnvironmentRpcSubscriptionObservation,
114): BackgroundScope | null {
115 if (observation.method === WS_METHODS.subscribeResourceTelemetry) {
116 return { type: "diagnostics" };
117 }
118 if (observation.method !== WS_METHODS.subscribeVcsStatus) {
119 return null;
120 }
121 const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown };
122 if (input.includeRemote === false) return null;
123 return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null;
124}
125
126function retainBackgroundScope(environmentId: EnvironmentId, scope: BackgroundScope): () => void {
127 const key = stableScopeKey(environmentId, scope);
128 const existing = retainedScopes.get(key);
129 if (existing) {
130 existing.refCount += 1;
131 } else {
132 retainedScopes.set(key, { environmentId, scope, refCount: 1 });
133 notifyRetainedScopesChanged();
134 }
135
136 return () => {
137 const current = retainedScopes.get(key);
138 if (!current) return;
139 current.refCount -= 1;
140 if (current.refCount <= 0) {
141 retainedScopes.delete(key);
142 notifyRetainedScopesChanged();
143 }
144 };
145}
146
147export function observeBackgroundActivitySubscription(
148 observation: EnvironmentRpcSubscriptionObservation,
149): Effect.Effect<Effect.Effect<void>> {
150 const scope = scopeForSubscription(observation);
151 if (scope === null) {
152 return Effect.succeed(Effect.void);
153 }
154 return Effect.sync(() => {
155 const release = retainBackgroundScope(observation.environmentId as EnvironmentId, scope);
156 return Effect.sync(release);
157 });
158}
159
160export function retainedBackgroundScopes(
161 environmentId: EnvironmentId,
162): ReadonlyArray<BackgroundScope> {
163 return Array.from(retainedScopes.values(), (entry) =>
164 entry.environmentId === environmentId ? entry.scope : null,
165 ).filter((scope): scope is BackgroundScope => scope !== null);
166}
167
168export const layerObserver = Layer.succeed(
169 EnvironmentRpcSubscriptionObserver,
170 EnvironmentRpcSubscriptionObserver.of({
171 observe: observeBackgroundActivitySubscription,
172 }),
173);
174
175export const layer = Layer.effectDiscard(
176 Effect.gen(function* () {
177 if (typeof window === "undefined" || typeof document === "undefined") {
178 return;
179 }
180
181 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
182 const clock = yield* Clock.Clock;
183 const reportRequests = yield* Queue.sliding<void>(1);
184 const requestReport = () => Queue.offerUnsafe(reportRequests, undefined);
185 let lastInteractionAtMs = clock.currentTimeMillisUnsafe();
186 const recordInteraction = () => {
187 const observedAtMs = clock.currentTimeMillisUnsafe();
188 const wasRecent = wasRecentlyInteracted(lastInteractionAtMs, observedAtMs);
189 lastInteractionAtMs = observedAtMs;
190 if (!wasRecent) {
191 requestReport();
192 }
193 };
194 const passiveListenerOptions = { passive: true } as const;
195
196 const report = Effect.gen(function* () {
197 const observedAtMs = yield* Clock.currentTimeMillis;
198 const entries = yield* SubscriptionRef.get(registry.entries);
199 yield* Effect.forEach(
200 entries.keys(),
201 (environmentId) =>
202 registry
203 .run(
204 environmentId,
205 request(
206 WS_METHODS.serverReportClientActivity,
207 createActivityReport(environmentId, lastInteractionAtMs, observedAtMs),
208 ),
209 )
210 .pipe(Effect.ignore),
211 { concurrency: "unbounded", discard: true },
212 );
213 }).pipe(Effect.withSpan("web.backgroundActivity.report"));
214
215 yield* Effect.acquireRelease(
216 Effect.sync(() => {
217 retainedScopeListeners.add(requestReport);
218 document.addEventListener("visibilitychange", requestReport);
219 window.addEventListener("focus", requestReport);
220 window.addEventListener("blur", requestReport);
221 window.addEventListener("online", requestReport);
222 window.addEventListener("pointermove", recordInteraction);
223 window.addEventListener("keydown", recordInteraction);
224 window.addEventListener("wheel", recordInteraction, passiveListenerOptions);
225 window.addEventListener("touchstart", recordInteraction, passiveListenerOptions);
226 }),
227 () =>
228 Effect.sync(() => {
229 retainedScopeListeners.delete(requestReport);
230 document.removeEventListener("visibilitychange", requestReport);
231 window.removeEventListener("focus", requestReport);
232 window.removeEventListener("blur", requestReport);
233 window.removeEventListener("online", requestReport);
234 window.removeEventListener("pointermove", recordInteraction);
235 window.removeEventListener("keydown", recordInteraction);
236 window.removeEventListener("wheel", recordInteraction);
237 window.removeEventListener("touchstart", recordInteraction);
238 }),
239 );
240
241 yield* SubscriptionRef.changes(registry.entries).pipe(
242 Stream.runForEach(() => Effect.sync(requestReport)),
243 Effect.forkScoped,
244 );
245 yield* Stream.fromQueue(reportRequests).pipe(
246 Stream.debounce("250 millis"),
247 Stream.runForEach(() => report),
248 Effect.forkScoped,
249 );
250 yield* Effect.sync(requestReport).pipe(
251 Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)),
252 Effect.forkScoped,
253 );
254 }),
255);