Skip to content
Source
Sign inSign up

assets.ts

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