Rhys Sullivanfmtc92c57d6 months agoHistory
162 lines · 6.2 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// loadPluginsFromJsonc — runtime plugin loader.//// Reads `executor.jsonc#plugins`, dynamically imports each package's// `./server` entry via jiti (so workspace TS sources work in dev and// published `dist/*.js` works after install), and calls the exported// `definePlugin(...)` factory with merged `options` plus host-injected// deps. Returns the resulting `Plugin[]` ready to hand to// `composePluginApi` / `createExecutor`.//// jiti is used instead of bare `import()` because://   - workspace plugins under monorepo dev expose `.ts` source via the//     `bun` export condition; Node's loader can't read those directly,//     jiti transpiles on the fly.//   - in a published environment the package's `default` condition//     points at `dist/*.js`, which jiti loads as a normal ESM module.//// The convention is: every plugin package exports a `./server` subpath// whose default export is a `ConfiguredPlugin` (the result of// `definePlugin(...)`). Calling that with `{ ...options, ...deps }`// returns a concrete `Plugin`.// --------------------------------------------------------------------------- import { createRequire } from "node:module";import { dirname, isAbsolute, resolve as resolvePath } from "node:path";import { pathToFileURL } from "node:url";import * as fs from "node:fs";import * as jsonc from "jsonc-parser";import { Effect, Schema } from "effect"; import type { AnyPlugin } from "@executor-js/sdk"; // Plugins are invoked dynamically by name — exact author types are// unknown at the call site, so the loader treats every factory as// `(options?: unknown) => AnyPlugin`. The plugin author's types still// hold inside the plugin's own module; we just don't propagate them// across the runtime boundary.type LooseConfiguredPlugin = (options?: Record<string, unknown>) => AnyPlugin; import { ExecutorFileConfig } from "./schema"; export class LoadPluginsError extends Schema.TaggedErrorClass<LoadPluginsError>()(  "LoadPluginsError",  {    message: Schema.String,    cause: Schema.optional(Schema.Unknown),  },) {} export interface LoadPluginsFromJsoncOptions {  /** Absolute path to `executor.jsonc` (or compatible). */  readonly path: string;  /**   * Host-injected deps merged into each plugin's options. Common keys:   * `configFile` (the `ConfigFileSink`), env-derived credentials, etc.   * Plugins ignore deps they don't accept — `definePlugin` strips   * unknown keys before forwarding to the author factory.   */  readonly deps?: Readonly<Record<string, unknown>>;} /** * Returns the plugins listed in jsonc, or `null` if the file is missing * or has no `plugins` array. The host treats `null` as "fall back to * the static `executor.config.ts` factory." */export const loadPluginsFromJsonc = async (  options: LoadPluginsFromJsoncOptions,): Promise<readonly AnyPlugin[] | null> => Effect.runPromise(loadPluginsFromJsoncEffect(options)); const loadPluginsFromJsoncEffect = (  options: LoadPluginsFromJsoncOptions,): Effect.Effect<readonly AnyPlugin[] | null, LoadPluginsError> =>  Effect.gen(function* () {    const { path, deps } = options;    if (!fs.existsSync(path)) return null;     const raw = fs.readFileSync(path, "utf8");    const errors: jsonc.ParseError[] = [];    const parsed = jsonc.parse(raw, errors);    if (errors.length > 0) {      const msg = errors        .map((e) => `offset ${e.offset}: ${jsonc.printParseErrorCode(e.error)}`)        .join("; ");      return yield* new LoadPluginsError({        message: `[load-plugins] failed to parse ${path}: ${msg}`,      });    }     const config = yield* Schema.decodeUnknownEffect(ExecutorFileConfig)(parsed).pipe(      Effect.mapError(        (error) =>          new LoadPluginsError({            message: `[load-plugins] failed to decode ${path}: ${error.issue.toString()}`,            cause: error,          }),      ),    );     const entries = config.plugins ?? null;    if (!entries || entries.length === 0) return null;     // jiti is created once per call; `moduleCache: false` ensures a    // restart picks up freshly-installed packages without process restart    // (relevant when the dev server kicks a reload after `executor plugin    // install`).    const { createJiti } = yield* Effect.tryPromise({      try: () => import("jiti"),      catch: (cause) =>        new LoadPluginsError({          message: `[load-plugins] failed to import jiti.`,          cause,        }),    });    const jiti = createJiti(pathToFileURL(path).href, {      interopDefault: true,      moduleCache: false,    });     const fromDir = dirname(path);    // require.resolve is anchored to the jsonc's directory so plugin    // packages resolve from the host app's `node_modules` regardless of    // CWD.    const require = createRequire(isAbsolute(path) ? path : resolvePath(fromDir, "_anchor.js"));     const loaded: AnyPlugin[] = [];    for (const entry of entries) {      const serverEntry = `${entry.package}/server`;      const resolved = yield* Effect.try({        try: () => require.resolve(serverEntry),        catch: (cause) =>          new LoadPluginsError({            message:              `[load-plugins] cannot resolve "${serverEntry}" from ${fromDir}. ` +              `Is "${entry.package}" installed and does it export "./server"?`,            cause,          }),      });      const mod = (yield* Effect.tryPromise({        try: () => jiti.import(resolved),        catch: (cause) =>          new LoadPluginsError({            message: `[load-plugins] failed to import "${serverEntry}" from ${resolved}.`,            cause,          }),      })) as { default?: LooseConfiguredPlugin } | LooseConfiguredPlugin;      const factory = (        typeof mod === "function" ? mod : (mod.default ?? null)      ) as LooseConfiguredPlugin | null;      if (!factory || typeof factory !== "function") {        return yield* new LoadPluginsError({          message:            `[load-plugins] "${serverEntry}" did not export a default ` +            `definePlugin(...) factory.`,        });      }      const merged = { ...(deps ?? {}), ...(entry.options ?? {}) };      loaded.push(factory(merged));    }     return loaded;  });