219 lines · 8.2 KB · TypeScript
RawDownload
import {  type AssetCreateUrlInput,  type AssetCreateUrlResult,  type AssetImageDimensions,  AssetResource,  EnvironmentId,  WS_METHODS,} from "@t3tools/contracts";import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";import { isWindowsAbsolutePath } from "@t3tools/shared/path";import {  getProjectFaviconResourceKey,  isProjectFaviconFallbackUrl,} from "@t3tools/shared/projectFavicon";import * as Effect from "effect/Effect";import * as Option from "effect/Option";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import * as EnvironmentRegistry from "../connection/registry.ts";import * as EnvironmentSupervisor from "../connection/supervisor.ts";import { request } from "../rpc/client.ts";import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";import { createEnvironmentQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;const ASSET_URL_STALE_TIME_MS = 5 * 60_000;const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; export class InvalidAssetCollectionKeyError extends Schema.TaggedError<InvalidAssetCollectionKeyError>()(  "InvalidAssetCollectionKeyError",  {    key: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`;  }} const decodeAssetCollectionKey = Schema.decodeUnknownSync(  Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]),); export function parseAssetCollectionKey(  key: string,): readonly [EnvironmentId, ReadonlyArray<AssetResource>] {  try {    return decodeAssetCollectionKey(JSON.parse(key));  } catch (cause) {    throw new InvalidAssetCollectionKeyError({ key, cause });  }} export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {  try {    return new URL(relativeUrl, httpBaseUrl).toString();  } catch {    return null;  }} export const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe(  Atom.withLabel("asset-url:empty"),); export type AssetUrlState =  | { readonly _tag: "Loading" }  | { readonly _tag: "Failure" }  | {      readonly _tag: "Success";      readonly url: string;      /** The host path the server chose to serve, when it differs from what was asked for. */      readonly sourcePath?: string;      /** Pixel size from the image header, when the server could read one. */      readonly imageDimensions?: AssetImageDimensions;    }; export function assetUrlStateFromResult(  result: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,  httpBaseUrl: string | null,): AssetUrlState {  if (result._tag === "Failure") return { _tag: "Failure" };  if (httpBaseUrl === null || result._tag !== "Success") return { _tag: "Loading" };  const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);  if (url === null) return { _tag: "Failure" };  return {    _tag: "Success",    url,    ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}),    ...(result.value.imageDimensions !== undefined      ? { imageDimensions: result.value.imageDimensions }      : {}),  };} export function createAssetEnvironmentAtoms<R, E>(  runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,  localMediaEnvironment?: Atom.Atom<{    readonly environmentId: EnvironmentId;    readonly httpBaseUrl: string;  } | null>,) {  const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {    const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result);    if (Result.isSuccess(result)) return result.success;    const error = result.failure;    const resource = input.resource;    const local = localMediaEnvironment      ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment)      : null;    const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;    if (      !local ||      local.environmentId === supervisor.target.environmentId ||      !(        error._tag === "AssetWorkspaceAssetNotFoundError" ||        error._tag === "AssetWorkspaceAssetInspectionError" ||        error._tag === "AssetWorkspaceContextNotFoundError"      ) ||      resource._tag !== "media-file" ||      !(resource.path.startsWith("/") || isWindowsAbsolutePath(resource.path)) ||      mediaMimeTypeFromExtension(resource.path.slice(resource.path.lastIndexOf("."))) === null    )      return yield* error;    const registry = yield* EnvironmentRegistry.EnvironmentRegistry;    const asset = yield* registry.run(      local.environmentId,      request(WS_METHODS.assetsCreateUrl, input),    );    // Callers resolve against the thread's server, so preserve the local server's origin.    return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href };  });  const createUrl = createEnvironmentQueryAtomFamily(runtime, {    label: "environment-data:assets:create-url",    execute,    staleTimeMs: ASSET_URL_STALE_TIME_MS,    idleTtlMs: ASSET_URL_IDLE_TTL_MS,    refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,    refreshTrigger: ({ input }) =>      input.resource._tag === "media-file" ? localMediaEnvironment : undefined,  });  const createUrlsFamily = Atom.family((key: string) => {    const [environmentId, resources] = parseAssetCollectionKey(key);    return Atom.make((get) =>      resources.map((resource) =>        get(          createUrl({            environmentId,            input: { resource },          }),        ),      ),    ).pipe(      Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS),      Atom.withLabel(`environment-data:assets:create-urls:${key}`),    );  });   return {    createUrl,    createUrls: (target: {      readonly environmentId: EnvironmentId;      readonly resources: ReadonlyArray<AssetResource>;    }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])),  };} /** * Keeps project icons visible while their environment reconnects. Each resource * owns its last resolved URL, including a confirmed missing-icon response. */export function createProjectFaviconUrlAtomFamily(input: {  readonly imageCache?: ProjectFaviconCache;  readonly createUrl: (target: {    readonly environmentId: EnvironmentId;    readonly input: { readonly resource: AssetResource };  }) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>;  readonly preparedConnection: (    environmentId: EnvironmentId,  ) => Atom.Atom<Option.Option<{ readonly httpBaseUrl: string }>>;}) {  const decodeKey = Schema.decodeUnknownSync(    Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]),  );  const family = Atom.family((key: string) => {    const [environmentId, cwd, path] = decodeKey(JSON.parse(key));    const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) };    const request = input.createUrl({ environmentId, input: { resource } });    const resolvedUrl = Atom.make((get): string | null => {      const result = get(request);      const connection = get(input.preparedConnection(environmentId));      const state = assetUrlStateFromResult(        result,        Option.isSome(connection) ? connection.value.httpBaseUrl : null,      );      return state._tag === "Success" ? state.url : Option.getOrNull(get.self<string | null>());    }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));    const cache = input.imageCache;    if (!cache) return resolvedUrl;     const target = { environmentId, cwd, faviconPath: path };    const image = Atom.make((get) => {      get(request);      const url = get(resolvedUrl);      return Effect.promise((signal) => cache.resolve(target, url, signal));    }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));     return Atom.make((get): string | null => {      const result = get(image);      if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null;      return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target));    }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));  });  return (target: ProjectFaviconTarget) =>    family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath));}