648 lines · 25.2 KB · TypeScript
RawDownload
import * as NodeURL from "node:url"; import {  PluginCommandInvocationResult,  PluginPackageNotFoundError,  PluginPackageOperationError,  type PluginPackageDiscoveryError,  type PluginPackageOperation,  type PluginPackageStatus,  type PluginPackageStatusSnapshot,} from "@t3tools/contracts";import type { PluginActivationContext, PluginDefinition } from "@t3tools/plugin-runtime";import {  PluginManifest,  type PluginManifest as PluginManifestType,} from "@t3tools/plugin-runtime/manifest";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts";import * as ServerSettings from "../serverSettings.ts";import * as PluginCommandCatalog from "./PluginCommandCatalog.ts"; const MANIFEST_FILE_NAME = "t3-plugin.json";const COMMAND_CAPABILITY = "t3.commands@1"; interface DiscoveredPackage {  readonly directory: string;  readonly manifest: PluginManifestType;} interface DiscoveryResult {  readonly errors: ReadonlyArray<PluginPackageDiscoveryError>;  readonly packages: ReadonlyMap<string, DiscoveredPackage>;} interface LoadedDefinition {  readonly cacheDirectory: string;  readonly definition: PluginDefinition;  readonly retired: Promise<void>;} export interface PluginPackageApi {  readonly onDispose: (cleanup: () => void | Promise<void>) => void;  readonly registerCommand: (    command: {      readonly id: string;      readonly label: string;      readonly description?: string;      readonly surfaces: ReadonlyArray<"web" | "desktop" | "mobile">;    },    handler: () => unknown | Promise<unknown>,  ) => void;} type PluginPackageActivator = (api: PluginPackageApi) => void | Promise<void>; const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest));const decodeInvocationResult = Schema.decodeUnknownEffect(PluginCommandInvocationResult);const isPluginPackageOperationError = Schema.is(PluginPackageOperationError); const detailFromUnknown = (error: unknown): string => {  if (isPluginPackageOperationError(error)) {    if (error.detail !== undefined) return error.detail;    if (error.cause !== undefined) return detailFromUnknown(error.cause);  }  if (typeof error === "object" && error !== null && "cause" in error) {    const cause = error.cause;    if (cause !== undefined && cause !== error) return detailFromUnknown(cause);  }  const detail = error instanceof Error ? error.message : String(error);  const trimmed = detail.trim();  return (trimmed.length === 0 ? "unknown error" : trimmed).slice(0, 2_000);}; const detailFromCause = (cause: Cause.Cause<unknown>): string =>  detailFromUnknown(Cause.squash(cause)); const operationError = (  operation: PluginPackageOperation,  error: unknown,  id?: string,): PluginPackageOperationError => {  if (isPluginPackageOperationError(error)) return error;  return new PluginPackageOperationError({    ...(id === undefined ? {} : { id }),    operation,    ...(typeof error === "string" ? { detail: error } : { cause: error }),  });}; const makeDefinition = (  discovered: DiscoveredPackage,  activatePackage: PluginPackageActivator,  onRetired: () => void,  onCleanupError: (error: unknown) => void,): PluginDefinition => {  const declaredCommands = new Set(discovered.manifest.contributes?.commands ?? []);   return {    id: discovered.manifest.id,    version: discovered.manifest.version,    activate(context: PluginActivationContext) {      context.onDispose(onRetired);      const api: PluginPackageApi = {        onDispose(cleanup) {          context.onDispose(async () => {            try {              await cleanup();            } catch (error) {              onCleanupError(error);              throw error;            }          });        },        registerCommand(command, handler) {          if (!discovered.manifest.capabilities.includes(COMMAND_CAPABILITY)) {            throw new Error(`Manifest does not declare capability ${COMMAND_CAPABILITY}`);          }          if (!declaredCommands.has(command.id)) {            throw new Error(`Command ${command.id} is not declared in the manifest`);          }          PluginCommandCatalog.registerPluginCommand(context, {            command,            handler: Effect.tryPromise({              try: async () => handler(),              catch: (cause) => new PluginCommandCatalog.PluginCommandExecutionError({ cause }),            }).pipe(              Effect.flatMap((result) =>                decodeInvocationResult(result).pipe(                  Effect.mapError(                    (cause) => new PluginCommandCatalog.PluginCommandExecutionError({ cause }),                  ),                ),              ),            ),          });        },      };      return activatePackage(api);    },  };}; export class PluginPackageManager extends Context.Service<  PluginPackageManager,  {    readonly status: Effect.Effect<PluginPackageStatusSnapshot, PluginPackageOperationError>;    readonly enable: (      id: string,    ) => Effect.Effect<      PluginPackageStatusSnapshot,      PluginPackageNotFoundError | PluginPackageOperationError    >;    readonly disable: (      id: string,    ) => Effect.Effect<      PluginPackageStatusSnapshot,      PluginPackageNotFoundError | PluginPackageOperationError    >;    readonly reload: (      id: string,    ) => Effect.Effect<      PluginPackageStatusSnapshot,      PluginPackageNotFoundError | PluginPackageOperationError    >;  }>()("t3/plugins/PluginPackageManager") {} export const make = Effect.fn("PluginPackageManager.make")(function* () {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const config = yield* ServerConfig.ServerConfig;  const settings = yield* ServerSettings.ServerSettingsService;  const catalog = yield* PluginCommandCatalog.PluginCommandCatalog;  const semaphore = yield* Semaphore.make(1);  const pluginsDirectory = path.join(config.stateDir, "plugins");  const pluginCacheDirectory = path.join(config.stateDir, "plugin-cache");  const activeDefinitions = new Map<string, PluginDefinition>();  const activeCacheDirectories = new Map<string, string>();  const activeManifests = new Map<string, PluginManifestType>();  const activeRetirements = new Map<string, Promise<void>>();  const packageErrors = new Map<string, string>();  let loadSequence = 0;   const removeCacheDirectory = (directory: string) =>    fileSystem      .remove(directory, { recursive: true, force: true })      .pipe(        Effect.catch((error) =>          Effect.logWarning("Failed to remove local plugin package cache", { directory, error }),        ),      );   const validatePackageTree = Effect.fn("PluginPackageManager.validatePackageTree")(function* (    discovered: DiscoveredPackage,    operation: PluginPackageOperation,  ) {    const canonicalPluginsDirectory = yield* fileSystem      .realPath(pluginsDirectory)      .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id)));    const relativeRoot = path.relative(pluginsDirectory, discovered.directory);    const pending: Array<readonly [lexical: string, expectedCanonical: string]> = [      [discovered.directory, path.resolve(canonicalPluginsDirectory, relativeRoot)],    ];    while (pending.length > 0) {      const current = pending.pop();      if (current === undefined) continue;      const [lexical, expectedCanonical] = current;      const canonical = yield* fileSystem        .realPath(lexical)        .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id)));      if (path.normalize(canonical) !== path.normalize(expectedCanonical)) {        return yield* operationError(          operation,          "symbolic links are not supported in trusted local plugin packages",          discovered.manifest.id,        );      }      const info = yield* fileSystem        .stat(lexical)        .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id)));      if (info.type !== "Directory") continue;      const entries = yield* fileSystem        .readDirectory(lexical)        .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id)));      for (const entry of entries) {        pending.push([path.join(lexical, entry), path.join(expectedCanonical, entry)]);      }    }  });   const discover = Effect.fn("PluginPackageManager.discover")(function* (    operation: PluginPackageOperation,  ) {    yield* fileSystem      .makeDirectory(pluginsDirectory, { recursive: true })      .pipe(Effect.mapError((error) => operationError(operation, error)));    const entries = yield* fileSystem      .readDirectory(pluginsDirectory)      .pipe(Effect.mapError((error) => operationError(operation, error)));    const discovered = new Map<string, DiscoveredPackage>();    const errors: Array<PluginPackageDiscoveryError> = [];     for (const entry of [...entries].sort()) {      const directory = path.join(pluginsDirectory, entry);      const manifestPath = path.join(directory, MANIFEST_FILE_NAME);      if (        !(yield* fileSystem          .exists(manifestPath)          .pipe(Effect.mapError((error) => operationError(operation, error))))      )        continue;       const decoded = yield* Effect.exit(        fileSystem.readFileString(manifestPath).pipe(Effect.flatMap(decodeManifestJson)),      );      if (decoded._tag === "Failure") {        errors.push({ directory: entry, error: detailFromUnknown(decoded.cause) });        continue;      }      const packageManifest = decoded.value;      if (packageManifest.entrypoints.server === undefined) {        errors.push({ directory: entry, error: "manifest must define entrypoints.server" });        continue;      }      if (discovered.has(packageManifest.id)) {        errors.push({ directory: entry, error: `duplicate package id ${packageManifest.id}` });        continue;      }      discovered.set(packageManifest.id, { directory, manifest: packageManifest });    }     return { errors, packages: discovered } satisfies DiscoveryResult;  });   const loadDefinition = Effect.fn("PluginPackageManager.loadDefinition")(function* (    discovered: DiscoveredPackage,    operation: PluginPackageOperation,  ) {    const serverEntrypoint = discovered.manifest.entrypoints.server;    if (serverEntrypoint === undefined) {      return yield* operationError(        operation,        "manifest must define entrypoints.server",        discovered.manifest.id,      );    }    yield* validatePackageTree(discovered, operation);    const sourceEntrypointPath = path.resolve(discovered.directory, serverEntrypoint);    const relativeEntrypoint = path.relative(discovered.directory, sourceEntrypointPath);    if (      relativeEntrypoint === ".." ||      relativeEntrypoint.startsWith(`..${path.sep}`) ||      path.isAbsolute(relativeEntrypoint)    ) {      return yield* operationError(        operation,        "entrypoint escapes the package directory",        discovered.manifest.id,      );    }     const cacheDirectory = path.join(      pluginCacheDirectory,      discovered.manifest.id,      String(loadSequence++),    );    yield* fileSystem      .makeDirectory(path.dirname(cacheDirectory), { recursive: true })      .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id)));    const copied = yield* Effect.exit(      fileSystem        .copy(discovered.directory, cacheDirectory)        .pipe(Effect.mapError((error) => operationError(operation, error, discovered.manifest.id))),    );    if (copied._tag === "Failure") {      yield* removeCacheDirectory(cacheDirectory);      return yield* Effect.failCause(copied.cause);    }    const entrypointPath = path.resolve(cacheDirectory, serverEntrypoint);     const loaded = yield* Effect.exit(      Effect.gen(function* () {        const moduleUrl = NodeURL.pathToFileURL(entrypointPath);        const module = yield* Effect.tryPromise({          try: () => import(/* @vite-ignore */ moduleUrl.href) as Promise<Record<string, unknown>>,          catch: (cause) => operationError(operation, cause, discovered.manifest.id),        });        if (typeof module.default !== "function") {          return yield* operationError(            operation,            "server entrypoint must export a default activation function",            discovered.manifest.id,          );        }        return module.default as PluginPackageActivator;      }),    );    if (loaded._tag === "Failure") {      yield* removeCacheDirectory(cacheDirectory);      return yield* Effect.failCause(loaded.cause);    }     let markRetired: () => void = () => {};    const retired = new Promise<void>((resolve) => {      markRetired = resolve;    });    return {      cacheDirectory,      definition: makeDefinition(discovered, loaded.value, markRetired, (error) => {        packageErrors.set(discovered.manifest.id, detailFromUnknown(error));      }),      retired,    } satisfies LoadedDefinition;  });   const definitionList = (replacement?: readonly [string, PluginDefinition | undefined]) => {    const definitions = new Map(activeDefinitions);    if (replacement !== undefined) {      const [id, definition] = replacement;      if (definition === undefined) definitions.delete(id);      else definitions.set(id, definition);    }    return [...definitions.values()].sort((left, right) => left.id.localeCompare(right.id));  };   const readEnabledIds = settings.getSettings.pipe(    Effect.map((current) => new Set(current.enabledPluginIds)),  );   const persistEnabledIds = (    ids: ReadonlySet<string>,    operation: PluginPackageOperation,    id?: string,  ) =>    settings.setEnabledPluginIds([...ids].sort()).pipe(      Effect.mapError((error) => operationError(operation, error, id)),      Effect.asVoid,    );   const statusUnlocked = Effect.fn("PluginPackageManager.status")(function* (    operation: PluginPackageOperation,  ): Effect.fn.Return<PluginPackageStatusSnapshot, PluginPackageOperationError> {    const [discovery, enabledIds] = yield* Effect.all(      [        discover(operation),        readEnabledIds.pipe(Effect.mapError((error) => operationError(operation, error))),      ],      { concurrency: "unbounded" },    );    const discovered = discovery.packages;    const errors = [...discovery.errors];    const packages: Array<PluginPackageStatus> = [];    const packageIds = new Set([...discovered.keys(), ...activeManifests.keys()]);     for (const id of [...packageIds].sort()) {      const activeManifest = activeManifests.get(id);      const packageManifest = activeManifest ?? discovered.get(id)?.manifest;      if (packageManifest === undefined) continue;      const enabled = enabledIds.has(id);      const active = activeDefinitions.has(id);      const error =        packageErrors.get(id) ?? (enabled && !active ? "enabled package is not active" : undefined);      packages.push({        id: packageManifest.id,        version: packageManifest.version,        apiVersion: packageManifest.apiVersion,        enabled,        state: error !== undefined ? "error" : active ? "active" : "disabled",        capabilities: [...packageManifest.capabilities],        contributions: { commands: [...(packageManifest.contributes?.commands ?? [])] },        ...(error === undefined ? {} : { error }),      });    }     for (const id of [...enabledIds].sort()) {      if (!discovered.has(id)) {        errors.push({ directory: id, error: "enabled package was not discovered" });      }    }     return { errors, packages };  });   const transition = Effect.fn("PluginPackageManager.transition")(    (operation: "enable" | "reload", id: string) =>      Effect.uninterruptibleMask((restore) =>        Effect.gen(function* () {          const discovery = yield* restore(discover(operation));          const pluginPackage = discovery.packages.get(id);          if (pluginPackage === undefined) return yield* new PluginPackageNotFoundError({ id });          const enabledIds = yield* restore(            readEnabledIds.pipe(Effect.mapError((error) => operationError(operation, error, id))),          );          if (operation === "reload" && !enabledIds.has(id)) {            return yield* operationError(operation, "package is not enabled", id);          }          if (operation === "enable" && activeDefinitions.has(id) && enabledIds.has(id)) {            return yield* statusUnlocked(operation);          }          packageErrors.delete(id);           const previousEnabledIds = new Set(enabledIds);          const previousCacheDirectory = activeCacheDirectories.get(id);          const previousRetirement = activeRetirements.get(id);          const loadedExit = yield* Effect.exit(restore(loadDefinition(pluginPackage, operation)));          if (loadedExit._tag === "Failure") {            packageErrors.set(id, detailFromCause(loadedExit.cause));            return yield* Effect.failCause(loadedExit.cause);          }          const loaded = loadedExit.value;          if (operation === "enable") {            enabledIds.add(id);            const persisted = yield* Effect.exit(persistEnabledIds(enabledIds, operation, id));            if (persisted._tag === "Failure") {              packageErrors.set(id, detailFromCause(persisted.cause));              yield* removeCacheDirectory(loaded.cacheDirectory);              return yield* Effect.failCause(persisted.cause);            }          }          const previousCatalog = yield* catalog.list;          const reconciled = yield* Effect.exit(            restore(              catalog                .reconcile(definitionList([id, loaded.definition]))                .pipe(Effect.mapError((error) => operationError(operation, error, id))),            ),          );          if (reconciled._tag === "Failure") {            const currentCatalog = yield* catalog.list;            if (currentCatalog.generation === previousCatalog.generation) {              if (operation === "enable") {                const rolledBack = yield* Effect.exit(                  persistEnabledIds(previousEnabledIds, operation, id),                );                if (rolledBack._tag === "Failure") {                  packageErrors.set(id, detailFromCause(reconciled.cause));                  yield* removeCacheDirectory(loaded.cacheDirectory);                  yield* Effect.logWarning("Failed to restore enabled package settings", {                    id,                    error: rolledBack.cause,                  });                  return yield* Effect.failCause(reconciled.cause);                }              }              packageErrors.set(id, detailFromCause(reconciled.cause));              yield* removeCacheDirectory(loaded.cacheDirectory);              return yield* Effect.failCause(reconciled.cause);            }          }           activeDefinitions.set(id, loaded.definition);          activeCacheDirectories.set(id, loaded.cacheDirectory);

Lines 1–500 of 648.