1import { useAtomValue } from "@effect/atom-react";
2import {
3 createManagedRelayQueryManager,
4 createManagedRelayT3ChatLinkRequest,
5 deregisterManagedRelayEnvironment,
6 ManagedRelay,
7 managedRelaySessionAtom,
8 readManagedRelaySnapshotState,
9 unlinkManagedRelayT3Chat,
10} from "@t3tools/client-runtime/relay";
11import {
12 createAtomCommandScheduler,
13 createRuntimeCommand,
14} from "@t3tools/client-runtime/state/runtime";
15import {
16 DEFAULT_MANAGED_TUNNEL_LIMIT,
17 T3CHAT_PLAN_TUNNEL_LIMITS,
18 type RelayClientDeviceRecord,
19 type RelayClientEnvironmentRecord,
20 type RelayT3ChatLinkResponse,
21} from "@t3tools/contracts/relay";
22import type { EnvironmentId } from "@t3tools/contracts";
23import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import { AsyncResult, Atom } from "effect/reactivity";
27
28import { runtime } from "../lib/runtime";
29import { appAtomRegistry } from "../rpc/atomRegistry";
30
31const managedRelayAtomRuntime = Atom.runtime(
32 Layer.effect(
33 ManagedRelay.ManagedRelayClient,
34 runtime.contextEffect.pipe(
35 Effect.map((context) => Context.get(context, ManagedRelay.ManagedRelayClient)),
36 ),
37 ),
38);
39
40const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);
41
42const managedRelayMutationScheduler = createAtomCommandScheduler();
43
44export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand(
45 managedRelayAtomRuntime,
46 {
47 label: "web:managed-relay:deregister-environment",
48 scheduler: managedRelayMutationScheduler,
49 concurrency: {
50 mode: "serial",
51 key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) =>
52 input.accountId,
53 },
54 execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input),
55 },
56);
57
58const accountSerial = {
59 mode: "serial",
60 key: (input: { readonly accountId: string }) => input.accountId,
61} as const;
62
63export const createManagedRelayT3ChatLinkRequestCommand = createRuntimeCommand(
64 managedRelayAtomRuntime,
65 {
66 label: "web:managed-relay:create-t3chat-link-request",
67 scheduler: managedRelayMutationScheduler,
68 concurrency: accountSerial,
69 execute: (input: { readonly accountId: string }, registry) =>
70 createManagedRelayT3ChatLinkRequest(registry, input),
71 },
72);
73
74export const unlinkManagedRelayT3ChatCommand = createRuntimeCommand(managedRelayAtomRuntime, {
75 label: "web:managed-relay:unlink-t3chat",
76 scheduler: managedRelayMutationScheduler,
77 concurrency: accountSerial,
78 execute: (input: { readonly accountId: string }, registry) =>
79 unlinkManagedRelayT3Chat(registry, input),
80});
81
82const EMPTY_T3CHAT_LINK_ATOM = Atom.make(
83 AsyncResult.success<RelayT3ChatLinkResponse>({ available: false, link: null }),
84).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:t3chat-link:null"));
85
86export function useManagedRelayT3ChatLink() {
87 const session = useAtomValue(managedRelaySessionAtom);
88 const accountId = session?.accountId ?? null;
89 const atom = accountId
90 ? managedRelayQueryManager.t3ChatLinkAtom(accountId)
91 : EMPTY_T3CHAT_LINK_ATOM;
92 const snapshot = readManagedRelaySnapshotState(useAtomValue(atom));
93 const refresh = () => {
94 if (accountId) {
95 managedRelayQueryManager.refreshT3ChatLink(appAtomRegistry, accountId);
96 }
97 };
98
99 return { ...snapshot, accountId, refresh };
100}
101
102const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
103 AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
104).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null"));
105
106const EMPTY_DEVICES_ATOM = Atom.make(
107 AsyncResult.success<ReadonlyArray<RelayClientDeviceRecord>>([]),
108).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:devices:null"));
109
110export function useManagedRelayEnvironments() {
111 const session = useAtomValue(managedRelaySessionAtom);
112 const accountId = session?.accountId ?? null;
113 const atom = accountId
114 ? managedRelayQueryManager.environmentsAtom(accountId)
115 : EMPTY_ENVIRONMENTS_ATOM;
116 const result = useAtomValue(atom);
117 const snapshot = readManagedRelaySnapshotState(result);
118 const refresh = () => {
119 if (accountId) {
120 managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId);
121 }
122 };
123
124 return {
125 ...snapshot,
126 accountId,
127 refresh,
128 };
129}
130
131export function useManagedRelayDevices() {
132 const session = useAtomValue(managedRelaySessionAtom);
133 const accountId = session?.accountId ?? null;
134 const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM;
135 const result = useAtomValue(atom);
136 const snapshot = readManagedRelaySnapshotState(result);
137 const refresh = () => {
138 if (accountId) {
139 managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId);
140 }
141 };
142
143 return {
144 ...snapshot,
145 accountId,
146 refresh,
147 };
148}
149
150/** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */
151export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null;
152
153// Mirrors the relay's cap: a manual override is exact, otherwise the T3 Chat plan raises the default.
154function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): {
155 readonly limit: number;
156 readonly upgrade: ManagedTunnelUpgrade;
157} {
158 if (t3Chat.maxTunnelsOverride !== undefined) {
159 return { limit: t3Chat.maxTunnelsOverride, upgrade: null };
160 }
161 if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null };
162 switch (t3Chat.link?.plan) {
163 case "premier":
164 return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.premier, upgrade: null };
165 case "pro":
166 return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.pro, upgrade: "premier" };
167 case "free":
168 return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "pro" };
169 case undefined:
170 return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "link" };
171 }
172}
173
174/** Refetches the account's environments and T3 Chat plan after a tunnel changes. */
175export function refreshManagedTunnelUsage() {
176 const accountId = appAtomRegistry.get(managedRelaySessionAtom)?.accountId;
177 if (!accountId) return;
178 managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId);
179 managedRelayQueryManager.refreshT3ChatLink(appAtomRegistry, accountId);
180}
181
182/** Managed tunnels on this account and its cap. Null fields are still loading or failed. */
183export function useManagedTunnelUsage() {
184 const environments = useManagedRelayEnvironments();
185 const t3Chat = useManagedRelayT3ChatLink();
186 const used =
187 environments.accountId === null
188 ? null
189 : (environments.data?.filter(
190 (environment) => environment.endpoint.providerKind === "cloudflare_tunnel",
191 ).length ?? null);
192 const cap = t3Chat.accountId !== null && t3Chat.data ? managedTunnelCap(t3Chat.data) : null;
193 return {
194 used,
195 limit: cap?.limit ?? null,
196 upgrade: cap?.upgrade ?? null,
197 refresh: refreshManagedTunnelUsage,
198 };
199}