1import {
2 ArchiveError,
3 type AssetCreateUrlInput,
4 type AssetCreateUrlResult,
5 type AssetImageDimensions,
6 AssetResource,
7 EnvironmentId,
8 type ProjectCloneSnapshot,
9 WS_METHODS,
10} from "@t3tools/contracts";
11import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
12import { isWindowsAbsolutePath } from "@t3tools/shared/path";
13import {
14 getProjectFaviconResourceKey,
15 isProjectFaviconFallbackUrl,
16} from "@t3tools/shared/projectFavicon";
17import { HttpClient } from "effect/http";
18import * as HubConnection from "../connection/hub.ts";
19import { fetchArchivedAssetUrl } from "./archiveAssetHttp.ts";
20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as Result from "effect/Result";
23import * as Schema from "effect/Schema";
24import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
25
26import * as EnvironmentRegistry from "../connection/registry.ts";
27import * as EnvironmentSupervisor from "../connection/supervisor.ts";
28import { environmentEndpointUrl } from "../environment/endpoint.ts";
29import { request, EnvironmentRpcUnavailableError } from "../rpc/client.ts";
30import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
31import { createEnvironmentQueryAtomFamily } from "./runtime.ts";
32import { authorizeHubSignedUrl } from "./hubSignedUrl.ts";
33
34const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
35const ASSET_URL_STALE_TIME_MS = 5 * 60_000;
36const ASSET_URL_IDLE_TTL_MS = 60 * 60_000;
37
38export class InvalidAssetCollectionKeyError extends Schema.TaggedError<InvalidAssetCollectionKeyError>()(
39 "InvalidAssetCollectionKeyError",
40 {
41 key: Schema.String,
42 cause: Schema.Defect(),
43 },
44) {
45 override get message(): string {
46 return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`;
47 }
48}
49
50const decodeAssetCollectionKey = Schema.decodeUnknownSync(
51 Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]),
52);
53const decodeAssetUrlKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, AssetResource]));
54
55export function parseAssetCollectionKey(
56 key: string,
57): readonly [EnvironmentId, ReadonlyArray<AssetResource>] {
58 try {
59 return decodeAssetCollectionKey(JSON.parse(key));
60 } catch (cause) {
61 throw new InvalidAssetCollectionKeyError({ key, cause });
62 }
63}
64
65export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {
66 try {
67 return environmentEndpointUrl(httpBaseUrl, relativeUrl);
68 } catch {
69 return null;
70 }
71}
72
73export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
74 Atom.withLabel("asset-url:empty"),
75);
76
77export type AssetUrlState =
78 | { readonly _tag: "Loading" }
79 | {
80 readonly _tag: "Failure";
81 /** Set when the session lacks `filesystem:read`; the file itself may be fine. */
82 readonly reason?: "missing-file-access";
83 }
84 | {
85 readonly _tag: "Success";
86 readonly url: string;
87 /** When the signed URL stops working, in epoch milliseconds. */
88 readonly expiresAt: number;
89 /** The host path the server chose to serve, when it differs from what was asked for. */
90 readonly sourcePath?: string;
91 /** Pixel size from the image header, when the server could read one. */
92 readonly imageDimensions?: AssetImageDimensions;
93 };
94
95/** Shown in place of a host file the session lacks `filesystem:read` for. */
96export const MISSING_FILE_ACCESS_MESSAGE =
97 "This connection cannot read host files. Pair again with a new link to view it.";
98
99/**
100 * The state for a host file this session cannot read yet, or null when the client
101 * should ask the host. The host requires `filesystem:read` for these resources, so
102 * clients skip the request. A connected session without the scope gets its own
103 * reason, so the UI can say to pair again instead of implying the file is gone.
104 */
105export function blockedAssetUrlState(
106 resource: AssetResource | null,
107 fileAccess: {
108 readonly canReadFiles: boolean;
109 readonly isPending: boolean;
110 readonly error: string | null;
111 },
112): AssetUrlState | null {
113 if (
114 resource === null ||
115 fileAccess.canReadFiles ||
116 (resource._tag !== "workspace-file" &&
117 resource._tag !== "media-file" &&
118 resource._tag !== "draft-workspace-file")
119 ) {
120 return null;
121 }
122 if (fileAccess.isPending) return { _tag: "Loading" };
123 // An error means the session check itself failed, such as when offline.
124 return fileAccess.error === null
125 ? { _tag: "Failure", reason: "missing-file-access" }
126 : { _tag: "Failure" };
127}
128
129export function assetUrlStateFromResult(
130 result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
131 httpBaseUrl: string | null,
132): AssetUrlState {
133 if (result._tag === "Failure") return { _tag: "Failure" };
134 if (httpBaseUrl === null || result._tag !== "Success") return { _tag: "Loading" };
135 const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
136 if (url === null) return { _tag: "Failure" };
137 return {
138 _tag: "Success",
139 url,
140 expiresAt: result.value.expiresAt,
141 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
142 ...(result.value.imageDimensions !== undefined
143 ? { imageDimensions: result.value.imageDimensions }
144 : {}),
145 };
146}
147
148export function createAssetEnvironmentAtoms<R, E>(
149 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
150 localMediaEnvironment?: Atom.Atom<{
151 readonly environmentId: EnvironmentId;
152 readonly httpBaseUrl: string;
153 } | null>,
154) {
155 const fromArchive = Effect.fn("assets.fromArchive")(function* (input: AssetCreateUrlInput) {
156 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
157 const hub = yield* Effect.serviceOption(HubConnection.HubConnection);
158 const http = yield* Effect.serviceOption(HttpClient.HttpClient);
159 if (Option.isNone(hub) || Option.isNone(http)) return Option.none<AssetCreateUrlResult>();
160 return yield* fetchArchivedAssetUrl(supervisor.target.environmentId, input.resource).pipe(
161 Effect.provideService(HubConnection.HubConnection, hub.value),
162 Effect.provideService(HttpClient.HttpClient, http.value),
163 Effect.option,
164 );
165 });
166 const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {
167 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
168 const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result);
169 if (Result.isSuccess(result)) return yield* authorizeHubSignedUrl(result.success);
170 const archived = yield* fromArchive(input);
171 if (Option.isSome(archived)) return archived.value;
172 const error = result.failure;
173 const resource = input.resource;
174 const local = localMediaEnvironment
175 ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment)
176 : null;
177 if (
178 !local ||
179 local.environmentId === supervisor.target.environmentId ||
180 !(
181 error._tag === "AssetWorkspaceAssetNotFoundError" ||
182 error._tag === "AssetWorkspaceAssetInspectionError" ||
183 error._tag === "AssetWorkspaceContextNotFoundError"
184 ) ||
185 resource._tag !== "media-file" ||
186 !(resource.path.startsWith("/") || isWindowsAbsolutePath(resource.path)) ||
187 mediaMimeTypeFromExtension(resource.path.slice(resource.path.lastIndexOf("."))) === null
188 )
189 return yield* error;
190 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
191 const asset = yield* registry.run(
192 local.environmentId,
193 request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.flatMap(authorizeHubSignedUrl)),
194 );
195 // Callers resolve against the thread's server, so preserve the local server's origin.
196 return { ...asset, relativeUrl: environmentEndpointUrl(local.httpBaseUrl, asset.relativeUrl) };
197 });
198 const createUrlQuery = createEnvironmentQueryAtomFamily(runtime, {
199 label: "environment-data:assets:create-url",
200 execute,
201 executeDisconnected: (input) =>
202 fromArchive(input).pipe(
203 Effect.flatMap(
204 Option.match({
205 onSome: Effect.succeed,
206 onNone: () =>
207 EnvironmentSupervisor.EnvironmentSupervisor.pipe(
208 Effect.flatMap((supervisor) =>
209 Effect.fail(
210 new EnvironmentRpcUnavailableError({
211 environmentId: supervisor.target.environmentId,
212 message: new ArchiveError({ reason: "unavailable" }).message,
213 }),
214 ),
215 ),
216 ),
217 }),
218 ),
219 ),
220 staleTimeMs: ASSET_URL_STALE_TIME_MS,
221 idleTtlMs: ASSET_URL_IDLE_TTL_MS,
222 refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,
223 refreshTrigger: ({ input }) =>
224 input.resource._tag === "media-file" ? localMediaEnvironment : undefined,
225 });
226 const signedUrlFamily = Atom.family((key: string) => {
227 const [environmentId, resource] = decodeAssetUrlKey(JSON.parse(key));
228 const query = createUrlQuery({ environmentId, input: { resource } });
229 return Atom.transform(
230 query,
231 (get) => {
232 const result = get(query);
233 if (
234 AsyncResult.isSuccess(result) &&
235 !result.waiting &&
236 (new URL(result.value.relativeUrl, "http://relative.invalid").searchParams.has(
237 "wsTicket",
238 ) ||
239 new URL(result.value.relativeUrl, "http://relative.invalid").pathname.includes(
240 "/api/archive/assets/",
241 ))
242 ) {
243 // @effect-diagnostics-next-line globalDate:off - Atom expiry schedules outside an Effect runtime.
244 const now = Date.now();
245 const remaining = Math.min(
246 ASSET_URL_REFRESH_INTERVAL_MS,
247 Math.max(0, result.value.expiresAt - now - 60_000),
248 );
249 if (remaining === 0) {
250 get.refresh(query);
251 return get.once(query);
252 }
253 // @effect-diagnostics-next-line globalTimers:off - Atom lifetime owns the refresh timer.
254 const handle = setTimeout(() => get.refresh(query), remaining);
255 get.addFinalizer(() => clearTimeout(handle));
256 }
257 return result;
258 },
259 { initialValueTarget: query },
260 ).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
261 });
262 const createUrl = (target: {
263 readonly environmentId: EnvironmentId;
264 readonly input: AssetCreateUrlInput;
265 }) => signedUrlFamily(JSON.stringify([target.environmentId, target.input.resource]));
266 const createUrlsFamily = Atom.family((key: string) => {
267 const [environmentId, resources] = parseAssetCollectionKey(key);
268 return Atom.make((get) =>
269 resources.map((resource) =>
270 get(
271 createUrl({
272 environmentId,
273 input: { resource },
274 }),
275 ),
276 ),
277 ).pipe(
278 Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS),
279 Atom.withLabel(`environment-data:assets:create-urls:${key}`),
280 );
281 });
282
283 return {
284 createUrl,
285 createUrls: (target: {
286 readonly environmentId: EnvironmentId;
287 readonly resources: ReadonlyArray<AssetResource>;
288 }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])),
289 };
290}
291
292/**
293 * Keeps project icons visible while their environment reconnects. Each resource
294 * owns its last resolved URL, including a confirmed missing-icon response.
295 */
296export function createProjectFaviconUrlAtomFamily(input: {
297 readonly imageCache?: ProjectFaviconCache;
298 readonly createUrl: (target: {
299 readonly environmentId: EnvironmentId;
300 readonly input: { readonly resource: AssetResource };
301 }) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>;
302 readonly preparedConnection: (
303 environmentId: EnvironmentId,
304 ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>;
305 /** The environment's tracked clones, empty when it reports none. */
306 readonly projectClones?: (
307 environmentId: EnvironmentId,
308 ) => Atom.Atom<ReadonlyArray<ProjectCloneSnapshot>>;
309}) {
310 const decodeKey = Schema.decodeUnknownSync(
311 Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),
312 );
313 const projectClones = input.projectClones;
314 const family = Atom.family((key: string) => {
315 const [environmentId, cwd, path] = decodeKey(JSON.parse(key));
316 const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };
317 const query = input.createUrl({ environmentId, input: { resource } });
318 // A cloned project exists before its files do, and the server reports its
319 // icon missing until the clone lands. Ask again whenever the clone's phase
320 // changes: the first list a client sees may already say done.
321 const request = projectClones
322 ? query.pipe(
323 Atom.makeRefreshOnSignal(
324 Atom.make(
325 (get) =>
326 get(projectClones(environmentId)).find((clone) => clone.destinationPath === cwd)
327 ?.phase ?? null,
328 ),
329 ),
330 )
331 : query;
332 const resolvedUrl = Atom.make((get): string | null => {
333 const result = get(request);
334 const connection = get(input.preparedConnection(environmentId));
335 const state = assetUrlStateFromResult(
336 result,
337 Option.isSome(connection) ? connection.value.httpBaseUrl : null,
338 );
339 return state._tag === "Success" ? state.url : Option.getOrNull(get.self<string | null>());
340 }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
341 const cache = input.imageCache;
342 if (!cache) return resolvedUrl;
343
344 const target = { environmentId, cwd, faviconPath: path };
345 const image = Atom.make((get) => {
346 get(request);
347 const url = get(resolvedUrl);
348 return Effect.promise((signal) => cache.resolve(target, url, signal));
349 }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
350
351 return Atom.make((get): string | null => {
352 const result = get(image);
353 if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null;
354 return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target));
355 }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
356 });
357 return (target: ProjectFaviconTarget) =>
358 family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath));
359}