1import { useAtomMount, useAtomSubscribe, useAtomValue } from "@effect/atom-react";
2import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
3import {
4 assetUrlStateFromResult,
5 blockedAssetUrlState,
6 createAssetEnvironmentAtoms,
7 createProjectFaviconUrlAtomFamily,
8 EMPTY_ASSET_URL_ATOM,
9 makeRevisionedAssetUrlFamily,
10} from "@t3tools/client-runtime/state/assets";
11import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
12import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
13import * as Clock from "effect/Clock";
14import * as Effect from "effect/Effect";
15import * as Schema from "effect/Schema";
16import { Atom, AsyncResult } from "effect/reactivity";
17
18import { environmentCatalog } from "../connection/catalog";
19import { connectionAtomRuntime } from "../connection/runtime";
20import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
21import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
22import { appAtomRegistry } from "./atom-registry";
23import { environmentProjectCloneListAtom } from "./projectClones";
24import { environmentSession, usePreparedConnection } from "./session";
25import { useEnvironmentPresentation } from "./presentation";
26import { useEnvironmentQuery } from "./query";
27import { useAtomQueryRunner } from "./use-atom-query-runner";
28
29export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";
30
31export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
32
33export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
34 imageCache: projectFaviconDatabaseCache,
35 createUrl: assetEnvironment.createUrl,
36 preparedConnection: environmentSession.preparedConnectionValueAtom,
37 projectClones: environmentProjectCloneListAtom,
38});
39
40const revisionedAssetUrl = makeRevisionedAssetUrlFamily(assetEnvironment.createUrl);
41
42/**
43 * Pass `revision` for a host file whose bytes can change under the same path; each new
44 * revision re-mints the URL so the image reloads.
45 */
46export function useAssetUrlState(
47 environmentId: EnvironmentId | null,
48 resource: AssetResource | null,
49 revision?: string | null,
50): AssetUrlState {
51 const fileAccessSession = useEnvironmentQuery(
52 environmentId === null ? null : environmentSession.sessionStateAtom(environmentId),
53 );
54 const fileEnvironment = useEnvironmentPresentation(environmentId);
55 const fileAccess = resolveFilesystemReadAccess({
56 isCatalogReady: fileEnvironment.isReady,
57 connection: fileEnvironment.presentation?.connection ?? null,
58 session: fileAccessSession.data,
59 sessionError: fileAccessSession.error,
60 });
61 const blocked = blockedAssetUrlState(resource, fileAccess);
62 const preparedConnection = usePreparedConnection(environmentId);
63 const connectionPhase = fileEnvironment.presentation?.connection.phase ?? "available";
64 const result = useAtomValue(
65 blocked !== null || environmentId === null || resource === null
66 ? EMPTY_ASSET_URL_ATOM
67 : revision === undefined
68 ? assetEnvironment.createUrl({ environmentId, input: { resource } })
69 : revisionedAssetUrl({ environmentId, input: { resource }, revision }),
70 );
71 const shared =
72 blocked ??
73 assetUrlStateFromResult(
74 result,
75 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
76 );
77 return deriveAssetUrlState({
78 connectionPhase,
79 shared: shared._tag === "Failure" && result.waiting ? { _tag: "Loading" } : shared,
80 });
81}
82
83export function useAssetUrl(
84 environmentId: EnvironmentId | null,
85 resource: AssetResource | null,
86): string | null {
87 const state = useAssetUrlState(environmentId, resource);
88 return state._tag === "Success" ? state.url : null;
89}
90
91/** Explicit playback and sharing must reauthorize files that may have been replaced on disk. */
92export function useRefreshAssetUrl(
93 environmentId: EnvironmentId | null,
94 resource: AssetResource | null,
95): () => Promise<string | null> {
96 const connection = usePreparedConnection(environmentId);
97 const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
98 const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
99 refresh: true,
100 reportFailure: false,
101 });
102 return async () => {
103 if (environmentId === null || resource === null || httpBaseUrl === null) return null;
104 const state = assetUrlStateFromResult(
105 await createUrl({ environmentId, input: { resource } }),
106 httpBaseUrl,
107 );
108 return state._tag === "Success" ? state.url : null;
109 };
110}
111
112/**
113 * {@link useRefreshAssetUrl} for async tasks that resolve several resources at once. Resolves
114 * null without a connection and rejects with the server's error when it refuses the URL.
115 */
116export async function refreshAssetUrl(
117 environmentId: EnvironmentId,
118 resource: AssetResource,
119 signal?: AbortSignal,
120): Promise<string | null> {
121 const connectionAtom = environmentSession.preparedConnectionValueAtom(environmentId);
122 if (appAtomRegistry.get(connectionAtom)._tag === "None") return null;
123 const atom = assetEnvironment.createUrl({ environmentId, input: { resource } });
124 const result = await executeAtomQuery(appAtomRegistry, atom, {
125 label: atom.label?.[0] ?? "asset url",
126 refresh: true,
127 reportFailure: false,
128 ...(signal ? { signal } : {}),
129 });
130 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
131 // A route can change while the query runs; resolve against the connection now current.
132 const connection = appAtomRegistry.get(connectionAtom);
133 if (connection._tag === "None") return null;
134 const state = assetUrlStateFromResult(result, connection.value.httpBaseUrl);
135 return state._tag === "Success" ? state.url : null;
136}
137
138export interface AuthorizedAssetUrl {
139 readonly url: string;
140 readonly authorizedAt: number;
141}
142
143interface FreshAssetUrlKey {
144 readonly environmentId: EnvironmentId | null;
145 readonly resource: AssetResource;
146 /** A reconnect changes the base URL, and any URL minted against the old one is unusable. */
147 readonly httpBaseUrl: string | null;
148 /** Bumped by Retry so a file replaced on disk is reauthorized. */
149 readonly revision: number;
150}
151
152class AssetUrlUnavailableError extends Schema.TaggedError<AssetUrlUnavailableError>()(
153 "AssetUrlUnavailableError",
154 {
155 message: Schema.String,
156 cause: Schema.optional(Schema.Defect()),
157 },
158) {}
159
160// Plain-object keys compare structurally (Effect `Equal`/`Hash`).
161const freshAssetUrlFamily = Atom.family((key: FreshAssetUrlKey) =>
162 Atom.make(
163 Effect.tryPromise({
164 try: async (signal) =>
165 key.environmentId === null
166 ? null
167 : await refreshAssetUrl(key.environmentId, key.resource, signal).catch(() => null),
168 catch: (cause) =>
169 new AssetUrlUnavailableError({
170 message: cause instanceof Error ? cause.message : "The attachment is unavailable.",
171 cause,
172 }),
173 }).pipe(
174 Effect.flatMap((url) =>
175 url
176 ? Effect.map(Clock.currentTimeMillis, (authorizedAt): AuthorizedAssetUrl => ({
177 url,
178 authorizedAt,
179 }))
180 : Effect.fail(
181 new AssetUrlUnavailableError({
182 message: "Reconnect to this environment and try again.",
183 }),
184 ),
185 ),
186 ),
187 ).pipe(Atom.withLabel("fresh-asset-url")),
188);
189
190const noFreshAssetUrl = Atom.make(
191 AsyncResult.initial<AuthorizedAssetUrl, AssetUrlUnavailableError>(),
192).pipe(Atom.withLabel("fresh-asset-url:none"));
193const noSessionGeneration = Atom.make(0);
194
195/**
196 * A signed URL minted when the reader mounts, not a cached one: cached links can
197 * expire while the client is suspended.
198 */
199export function useFreshAssetUrl(
200 environmentId: EnvironmentId | null,
201 resource: AssetResource | null,
202 revision: number,
203): AsyncResult.AsyncResult<AuthorizedAssetUrl, Error> {
204 const connection = usePreparedConnection(environmentId);
205 const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null;
206 const atom =
207 resource === null
208 ? noFreshAssetUrl
209 : freshAssetUrlFamily({ environmentId, resource, httpBaseUrl, revision });
210 // Minting fails at once while the environment is unreachable; mint again once a new session is
211 // up instead of keeping the error until Retry.
212 const sessionGeneration =
213 environmentId === null
214 ? noSessionGeneration
215 : environmentCatalog.sessionGenerationAtom(environmentId);
216 // A listener alone does not compute the atom, so nothing would follow the connection.
217 useAtomMount(sessionGeneration);
218 useAtomSubscribe(sessionGeneration, () => {
219 if (AsyncResult.isFailure(appAtomRegistry.get(atom))) appAtomRegistry.refresh(atom);
220 });
221 return useAtomValue(atom);
222}