1import { useAtomValue } from "@effect/atom-react";
2import type {
3 DesktopPreviewBridge,
4 DesktopPreviewWebviewConfig,
5 EnvironmentId,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import { AsyncResult, Atom } from "effect/reactivity";
11
12import { previewBridge } from "~/components/preview/previewBridge";
13
14const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000;
15const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000;
16
17export class PreviewWebviewBridgeUnavailableError extends Schema.TaggedError<PreviewWebviewBridgeUnavailableError>()(
18 "PreviewWebviewBridgeUnavailableError",
19 { environmentId: Schema.String },
20) {
21 override get message(): string {
22 return `Desktop preview configuration is unavailable for environment "${this.environmentId}".`;
23 }
24}
25
26export class PreviewWebviewConfigLoadError extends Schema.TaggedError<PreviewWebviewConfigLoadError>()(
27 "PreviewWebviewConfigLoadError",
28 {
29 environmentId: Schema.String,
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Failed to load desktop preview configuration for environment "${this.environmentId}".`;
35 }
36}
37
38export const PreviewWebviewConfigError = Schema.Union([
39 PreviewWebviewBridgeUnavailableError,
40 PreviewWebviewConfigLoadError,
41]);
42export type PreviewWebviewConfigError = typeof PreviewWebviewConfigError.Type;
43
44type PreviewConfigBridge = Pick<DesktopPreviewBridge, "getPreviewConfig">;
45
46export const loadPreviewWebviewConfig = (
47 environmentId: EnvironmentId,
48 profileId?: string,
49 bridge: PreviewConfigBridge | null = previewBridge,
50): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => {
51 if (bridge === null) {
52 return Effect.fail(new PreviewWebviewBridgeUnavailableError({ environmentId }));
53 }
54
55 return Effect.tryPromise({
56 try: () => bridge.getPreviewConfig(environmentId, profileId),
57 catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }),
58 });
59};
60
61/**
62 * `Atom.family` keys on its argument, so the environment and profile are
63 * folded into one string: passing an object would allocate a fresh entry on
64 * every render.
65 *
66 * The profile is the tail rather than a second field, so an id containing the
67 * delimiter round-trips whole instead of being truncated into a different
68 * profile's key. `BrowserProfileId` rejects control characters, which is what
69 * makes the environment side of the split unambiguous.
70 */
71const CONFIG_KEY_DELIMITER = "\u0000";
72
73const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string =>
74 `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`;
75
76const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => {
77 const delimiter = key.indexOf(CONFIG_KEY_DELIMITER);
78 const environmentId = (delimiter === -1 ? key : key.slice(0, delimiter)) as EnvironmentId;
79 const profileId = delimiter === -1 ? "" : key.slice(delimiter + CONFIG_KEY_DELIMITER.length);
80 return {
81 environmentId,
82 ...(profileId === "" ? {} : { profileId }),
83 };
84};
85
86const previewWebviewConfigAtom = Atom.family((key: string) => {
87 const { environmentId, profileId } = parseConfigKey(key);
88 return Atom.make(loadPreviewWebviewConfig(environmentId, profileId)).pipe(
89 Atom.swr({
90 staleTime: PREVIEW_CONFIG_STALE_TIME_MS,
91 revalidateOnMount: true,
92 }),
93 Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS),
94 Atom.withLabel(`preview:webview-config:${key}`),
95 );
96});
97
98export function usePreviewWebviewConfig(
99 environmentId: EnvironmentId,
100 profileId?: string,
101): DesktopPreviewWebviewConfig | null {
102 const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId)));
103 return Option.getOrNull(AsyncResult.value(result));
104}