Skip to content
Source
Sign inSign up

assets.ts

398 lines15.2 KB

1import {
2 type AssetCreateUrlInput,
3 type AssetCreateUrlResult,
4 type AssetImageDimensions,
5 AssetResource,
6 EnvironmentId,
7 type ProjectCloneSnapshot,
8 WS_METHODS,
9} from "@t3tools/contracts";
10import { mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview";
11import { isWindowsAbsolutePath } from "@t3tools/shared/path";
12import {
13 getProjectFaviconResourceKey,
14 isProjectFaviconFallbackUrl,
15} from "@t3tools/shared/projectFavicon";
16import * as Cause from "effect/Cause";
17import * as Effect from "effect/Effect";
18import * as Option from "effect/Option";
19import * as Result from "effect/Result";
20import * as Schema from "effect/Schema";
21import * as Semaphore from "effect/Semaphore";
22import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
23​
24import * as EnvironmentRegistry from "../connection/registry.ts";
25import * as EnvironmentSupervisor from "../connection/supervisor.ts";
26import { request } from "../rpc/client.ts";
27import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
28import { createEnvironmentQueryAtomFamily } from "./runtime.ts";
29​
30const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
31const ASSET_URL_IDLE_TTL_MS = 60 * 60_000;
32export const ASSET_URL_MAX_IN_FLIGHT = 6;
33​
34export class InvalidAssetCollectionKeyError extends Schema.TaggedError<InvalidAssetCollectionKeyError>()(
35 "InvalidAssetCollectionKeyError",
36 {
37 key: Schema.String,
38 cause: Schema.Defect(),
39 },
40) {
41 override get message(): string {
42 return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`;
43 }
45​
46const decodeAssetCollectionKey = Schema.decodeUnknownSync(
47 Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]),
48);
49​
50function parseAssetCollectionKey(
51 key: string,
52): readonly [EnvironmentId, ReadonlyArray<AssetResource>] {
53 try {
54 return decodeAssetCollectionKey(JSON.parse(key));
55 } catch (cause) {
56 throw new InvalidAssetCollectionKeyError({ key, cause });
57 }
59​
60export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {
61 try {
62 return new URL(relativeUrl, httpBaseUrl).toString();
63 } catch {
64 return null;
65 }
67​
68export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(
69 Atom.withLabel("asset-url:empty"),
70);
71​
72export type AssetUrlState =
73 | { readonly _tag: "Loading" }
74 | {
75 readonly _tag: "Failure";
76 readonly reason?: "missing-file-access";
77 }
78 | {
79 readonly _tag: "Success";
80 readonly url: string;
81 readonly expiresAt: number;
82 readonly sourcePath?: string;
83 readonly imageDimensions?: AssetImageDimensions;
84 readonly fileRevision?: string;
85 };
86​
87export const MISSING_FILE_ACCESS_MESSAGE =
88 "This connection cannot read host files. Pair again with a new link to view it.";
89​
90export function blockedAssetUrlState(
91 resource: AssetResource | null,
92 fileAccess: {
93 readonly canReadFiles: boolean;
94 readonly isPending: boolean;
95 readonly error: string | null;
96 },
97): AssetUrlState | null {
98 if (
99 resource === null ||
100 fileAccess.canReadFiles ||
101 (resource._tag !== "workspace-file" &&
102 resource._tag !== "media-file" &&
103 resource._tag !== "draft-workspace-file" &&
104 !(resource._tag === "native-app-icon" && resource.app._tag === "app-path"))
105 ) {
106 return null;
107 }
108 if (fileAccess.isPending) return { _tag: "Loading" };
109 return fileAccess.error === null
110 ? { _tag: "Failure", reason: "missing-file-access" }
111 : { _tag: "Failure" };
113​
114export function assetUrlStateFromResult(
115 result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
116 httpBaseUrl: string | null,
117): AssetUrlState {
118 if (result._tag === "Failure") return { _tag: "Failure" };
119 if (httpBaseUrl === null || result._tag !== "Success") return { _tag: "Loading" };
120 const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
121 if (url === null) return { _tag: "Failure" };
122 return {
123 _tag: "Success",
124 url,
125 expiresAt: result.value.expiresAt,
126 ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),
127 ...(result.value.imageDimensions !== undefined
128 ? { imageDimensions: result.value.imageDimensions }
129 : {}),
130 ...(result.value.fileRevision !== undefined ? { fileRevision: result.value.fileRevision } : {}),
131 };
133​
134type AssetUrlTarget = {
135 readonly environmentId: EnvironmentId;
136 readonly input: { readonly resource: AssetResource };
137};
138​
139const decodeRevisionedAssetUrlKey = Schema.decodeUnknownSync(
140 Schema.Tuple([EnvironmentId, AssetResource, Schema.NullOr(Schema.String)]),
141);
142​
143const isSettled = (result: AsyncResult.AsyncResult<unknown, unknown>) =>
144 !AsyncResult.isInitial(result) && !result.waiting;
145​
146const KEEP_URL_MIN_LIFE_MS = 5 * 60_000;
147​
148/**
149 * A re-mint signs a new URL even when the file is unchanged, and a new URL reloads the image.
150 * Keep the shown URL while the server reports the same file revision and it has time left.
151 */
152function keepUnchangedFile(
153 shown: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
154 next: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
155 now: number,
156) {
157 if (
158 shown._tag !== "Success" ||
159 next._tag !== "Success" ||
160 next.value.fileRevision === undefined ||
161 next.value.fileRevision !== shown.value.fileRevision ||
162 shown.value.expiresAt - now <= KEEP_URL_MIN_LIFE_MS
163 ) {
164 return next;
165 }
166 const kept = AsyncResult.success(shown.value);
167 return next.waiting ? AsyncResult.waiting(kept) : kept;
169​
170/**
171 * The URL is minted per resource and shared across revisions, so reading a
172 * different revision (even an earlier one after a revert) re-mints it. An
173 * in-flight mint is shared only on first read; after an edit the server must
174 * re-read the file, so the re-mint waits for that mint to settle.
175 */
176export function makeRevisionedAssetUrlFamily(
177 createUrl: (
178 target: AssetUrlTarget,
179 ) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>,
180 now: () => number = Date.now,
181) {
182 // Outside the nodes: a node is dropped once unmounted, and the next read of
183 // its revision must still see the revisions minted since. Never pruned on
184 // purpose: pruning would forget the revision and re-mint on the next mount.
185 // It holds one short entry per file shown, and goes with the registry.
186 const mintedUnder = new WeakMap<AtomRegistry.AtomRegistry, Map<string, string | null>>();
187 // Also per resource: a new revision builds a new node, which must keep the URL on screen.
188 const shownUnder = new WeakMap<
189 AtomRegistry.AtomRegistry,
190 Map<string, AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>
191 >();
192 const family = Atom.family((key: string) => {
193 const [environmentId, resource, revision] = decodeRevisionedAssetUrlKey(JSON.parse(key));
194 const resourceKey = JSON.stringify([environmentId, resource]);
195 const query = createUrl({ environmentId, input: { resource } });
196 return Atom.transform(
197 query,
198 (get) => {
199 let minted = mintedUnder.get(get.registry);
200 if (minted === undefined) {
201 minted = new Map();
202 mintedUnder.set(get.registry, minted);
203 }
204 const firstRead = !minted.has(resourceKey);
205 let pending = minted.get(resourceKey) !== revision;
206 const current = get.once(query);
207 if (pending && (firstRead || isSettled(current))) {
208 pending = false;
209 minted.set(resourceKey, revision);
210 if (isSettled(current)) get.refresh(query);
211 }
212 let shownByResource = shownUnder.get(get.registry);
213 if (shownByResource === undefined) {
214 shownByResource = new Map();
215 shownUnder.set(get.registry, shownByResource);
216 }
217 const show = (value: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>) => {
218 const previous = shownByResource.get(resourceKey);
219 const shown = previous === undefined ? value : keepUnchangedFile(previous, value, now());
220 shownByResource.set(resourceKey, shown);
221 return shown;
222 };
223 get.subscribe(query, (value) => {
224 get.setSelf(show(value));
225 if (!pending || !isSettled(value)) return;
226 pending = false;
227 minted.set(resourceKey, revision);
228 get.refresh(query);
229 });
230 return show(get.once(query));
231 },
232 { initialValueTarget: query },
233 // No idle TTL: each mount rebuilds the node, so a revision read again after
234 // another one is compared against the record instead of a cached node.
235 ).pipe(Atom.setIdleTTL(0));
236 });
237 return (target: AssetUrlTarget & { readonly revision: string | null }) =>
238 family(JSON.stringify([target.environmentId, target.input.resource, target.revision]));
240​
241export function createAssetEnvironmentAtoms<R, E>(
242 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
243 localMediaEnvironment?: Atom.Atom<{
244 readonly environmentId: EnvironmentId;
245 readonly httpBaseUrl: string;
246 } | null>,
247) {
248 // A thread with hundreds of images asks for hundreds of URLs at once. Over a slow remote
249 // they would all wait on one socket and read as stalled, so each environment gets a few
250 // at a time. A query that is interrupted, such as by a new session, gives its slot back.
251 const mintLimits = new Map<EnvironmentId, Semaphore.Semaphore>();
252 const mint = (environmentId: EnvironmentId, input: AssetCreateUrlInput) => {
253 let limit = mintLimits.get(environmentId);
254 if (limit === undefined) {
255 limit = Semaphore.makeUnsafe(ASSET_URL_MAX_IN_FLIGHT);
256 mintLimits.set(environmentId, limit);
257 }
258 return request(WS_METHODS.assetsCreateUrl, input).pipe(limit.withPermit);
259 };
260 const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {
261 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
262 const result = yield* mint(supervisor.target.environmentId, input).pipe(Effect.result);
263 if (Result.isSuccess(result)) return result.success;
264 const error = result.failure;
265 const resource = input.resource;
266 const local = localMediaEnvironment
267 ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment)
268 : null;
269 if (
270 !local ||
271 local.environmentId === supervisor.target.environmentId ||
272 !(
273 error._tag === "AssetWorkspaceAssetNotFoundError" ||
274 error._tag === "AssetWorkspaceAssetInspectionError" ||
275 error._tag === "AssetWorkspaceContextNotFoundError"
276 ) ||
277 resource._tag !== "media-file" ||
278 resource.hostOnly ||
279 !(resource.path.startsWith("/") || isWindowsAbsolutePath(resource.path)) ||
280 mediaMimeTypeFromExtension(resource.path.slice(resource.path.lastIndexOf("."))) === null
281 )
282 return yield* error;
283 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
284 // Map the full cause so retry defects and interruption survive.
285 const asset = yield* registry
286 .run(local.environmentId, mint(local.environmentId, input))
287 .pipe(Effect.catchCause((cause) => Effect.failCause(Cause.map(cause, () => error))));
288 return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href };
289 });
290 const createUrl = createEnvironmentQueryAtomFamily(runtime, {
291 label: "environment-data:assets:create-url",
292 execute,
293 // Each mint signs a new URL, so a remounted reader reuses its URL until renewal is due.
294 staleTimeMs: ASSET_URL_REFRESH_INTERVAL_MS,
295 idleTtlMs: ASSET_URL_IDLE_TTL_MS,
296 refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,
297 refreshTrigger: ({ input }) =>
298 input.resource._tag === "media-file" ? localMediaEnvironment : undefined,
299 });
300 const createUrlsFamily = Atom.family((key: string) => {
301 const [environmentId, resources] = parseAssetCollectionKey(key);
302 return Atom.make((get) =>
303 resources.map((resource) =>
304 get(
305 createUrl({
306 environmentId,
307 input: { resource },
308 }),
309 ),
310 ),
311 ).pipe(
312 Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS),
313 Atom.withLabel(`environment-data:assets:create-urls:${key}`),
314 );
315 });
316​
317 return {
318 createUrl,
319 createUrls: (target: {
320 readonly environmentId: EnvironmentId;
321 readonly resources: ReadonlyArray<AssetResource>;
322 }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])),
323 };
325​
326/**
327 * The resource a project's icon URL is minted for. Refreshing the `createUrl` query for it makes
328 * the server resolve the icon again instead of serving a cached answer.
329 */
330export function projectFaviconResource(cwd: string, path: string | null | undefined) {
331 return { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };
333​
334export function createProjectFaviconUrlAtomFamily(input: {
335 readonly imageCache?: ProjectFaviconCache;
336 readonly createUrl: (target: {
337 readonly environmentId: EnvironmentId;
338 readonly input: { readonly resource: AssetResource };
339 }) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>;
340 readonly preparedConnection: (
341 environmentId: EnvironmentId,
342 ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>;
343 readonly projectClones?: (
344 environmentId: EnvironmentId,
345 ) => Atom.Atom<ReadonlyArray<ProjectCloneSnapshot>>;
346}) {
347 const decodeKey = Schema.decodeUnknownSync(
348 Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),
349 );
350 const projectClones = input.projectClones;
351 const family = Atom.family((key: string) => {
352 const [environmentId, cwd, path] = decodeKey(JSON.parse(key));
353 const query = input.createUrl({
354 environmentId,
355 input: { resource: projectFaviconResource(cwd, path) },
356 });
357 // A cloned project exists before its files do, and the server reports its
358 // icon missing until the clone lands. Ask again whenever the clone's phase
359 // changes: the first list a client sees may already say done.
360 const request = projectClones
361 ? query.pipe(
362 Atom.makeRefreshOnSignal(
363 Atom.make(
364 (get) =>
365 get(projectClones(environmentId)).find((clone) => clone.destinationPath === cwd)
366 ?.phase ?? null,
367 ),
368 ),
369 )
370 : query;
371 const resolvedUrl = Atom.make((get): string | null => {
372 const result = get(request);
373 const connection = get(input.preparedConnection(environmentId));
374 const state = assetUrlStateFromResult(
375 result,
376 Option.isSome(connection) ? connection.value.httpBaseUrl : null,
377 );
378 return state._tag === "Success" ? state.url : Option.getOrNull(get.self<string | null>());
379 }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
380 const cache = input.imageCache;
381 if (!cache) return resolvedUrl;
382​
383 const target = { environmentId, cwd, faviconPath: path };
384 const image = Atom.make((get) => {
385 get(request);
386 const url = get(resolvedUrl);
387 return Effect.promise((signal) => cache.resolve(target, url, signal));
388 }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
389​
390 return Atom.make((get): string | null => {
391 const result = get(image);
392 if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null;
393 return AsyncResult.getOrElse(result, () => cache.peek(target));
394 }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
395 });
396 return (target: ProjectFaviconTarget) =>
397 family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath));