History

Parse plugin loader config with Schema

Rhys Sullivan committed 6 months ago

Commit 84e64a01 parent b039b6e1 file · +102 −61Browse files

Unified · Split

packages/core/config/src/load-plugins.ts+102−61
@@ -26,6 +26,7 @@ import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; · 25 unmodified lines
2626 import { pathToFileURL } from "node:url";
2727 import * as fs from "node:fs";
2828 import * as jsonc from "jsonc-parser";
29+import { Effect, Schema } from "effect";
2930
3031 import type { AnyPlugin } from "@executor-js/sdk";
3132
@@ -36,7 +37,15 @@ import type { AnyPlugin } from "@executor-js/sdk"; · 4 unmodified lines
3637 // across the runtime boundary.
3738 type LooseConfiguredPlugin = (options?: Record<string, unknown>) => AnyPlugin;
3839
39−import type { PluginConfig } from "./schema";
40+import { ExecutorFileConfig } from "./schema";
41+
42+export class LoadPluginsError extends Schema.TaggedErrorClass<LoadPluginsError>()(
43+ "LoadPluginsError",
44+ {
45+ message: Schema.String,
46+ cause: Schema.optional(Schema.Unknown),
47+ },
48+) {}
4049
4150 export interface LoadPluginsFromJsoncOptions {
4251 /** Absolute path to `executor.jsonc` (or compatible). */
@@ -57,70 +66,102 @@ export interface LoadPluginsFromJsoncOptions { · 14 unmodified lines
5766 */
5867 export const loadPluginsFromJsonc = async (
5968 options: LoadPluginsFromJsoncOptions,
60−): Promise<readonly AnyPlugin[] | null> => {
61− const { path, deps } = options;
62− if (!fs.existsSync(path)) return null;
69+): Promise<readonly AnyPlugin[] | null> =>
70+ Effect.runPromise(loadPluginsFromJsoncEffect(options));
6371
64− const raw = fs.readFileSync(path, "utf8");
65− const errors: jsonc.ParseError[] = [];
66− const parsed = jsonc.parse(raw, errors) as
67− | { plugins?: readonly PluginConfig[] }
68− | undefined;
69− if (errors.length > 0) {
70− const msg = errors
71− .map((e) => `offset ${e.offset}: ${jsonc.printParseErrorCode(e.error)}`)
72− .join("; ");
73− throw new Error(`[load-plugins] failed to parse ${path}: ${msg}`);
74− }
72+const loadPluginsFromJsoncEffect = (
73+ options: LoadPluginsFromJsoncOptions,
74+): Effect.Effect<readonly AnyPlugin[] | null, LoadPluginsError> =>
75+ Effect.gen(function* () {
76+ const { path, deps } = options;
77+ if (!fs.existsSync(path)) return null;
7578
76− const entries = parsed?.plugins ?? null;
77− if (!entries || entries.length === 0) return null;
79+ const raw = fs.readFileSync(path, "utf8");
80+ const errors: jsonc.ParseError[] = [];
81+ const parsed = jsonc.parse(raw, errors);
82+ if (errors.length > 0) {
83+ const msg = errors
84+ .map((e) => `offset ${e.offset}: ${jsonc.printParseErrorCode(e.error)}`)
85+ .join("; ");
86+ return yield* new LoadPluginsError({
87+ message: `[load-plugins] failed to parse ${path}: ${msg}`,
88+ });
89+ }
7890
79− // jiti is created once per call; `moduleCache: false` ensures a
80− // restart picks up freshly-installed packages without process restart
81− // (relevant when the dev server kicks a reload after `executor plugin
82− // install`).
83− const { createJiti } = await import("jiti");
84− const jiti = createJiti(pathToFileURL(path).href, {
85− interopDefault: true,
86− moduleCache: false,
87− });
91+ const config = yield* Schema.decodeUnknownEffect(ExecutorFileConfig)(parsed).pipe(
92+ Effect.mapError(
93+ (error) =>
94+ new LoadPluginsError({
95+ message: `[load-plugins] failed to decode ${path}: ${error.issue.toString()}`,
96+ cause: error,
97+ }),
98+ ),
99+ );
88100
89− const fromDir = dirname(path);
90− // require.resolve is anchored to the jsonc's directory so plugin
91− // packages resolve from the host app's `node_modules` regardless of
92− // CWD.
93− const require = createRequire(
94− isAbsolute(path) ? path : resolvePath(fromDir, "_anchor.js"),
95− );
101+ const entries = config.plugins ?? null;
102+ if (!entries || entries.length === 0) return null;
96103
97− const loaded: AnyPlugin[] = [];
98− for (const entry of entries) {
99− const serverEntry = `${entry.package}/server`;
100− let resolved: string;
101− try {
102− resolved = require.resolve(serverEntry);
103− } catch {
104− throw new Error(
105− `[load-plugins] cannot resolve "${serverEntry}" from ${fromDir}. ` +
106− `Is "${entry.package}" installed and does it export "./server"?`,
107− );
108− }
109− const mod = (await jiti.import(resolved)) as
110− | { default?: LooseConfiguredPlugin }
111− | LooseConfiguredPlugin;
112− const factory = (
113− typeof mod === "function" ? mod : (mod.default ?? null)
114− ) as LooseConfiguredPlugin | null;
115− if (!factory || typeof factory !== "function") {
116− throw new Error(
117− `[load-plugins] "${serverEntry}" did not export a default ` +
118− `definePlugin(...) factory.`,
119− );
104+ // jiti is created once per call; `moduleCache: false` ensures a
105+ // restart picks up freshly-installed packages without process restart
106+ // (relevant when the dev server kicks a reload after `executor plugin
107+ // install`).
108+ const { createJiti } = yield* Effect.tryPromise({
109+ try: () => import("jiti"),
110+ catch: (cause) =>
111+ new LoadPluginsError({
112+ message: `[load-plugins] failed to import jiti.`,
113+ cause,
114+ }),
115+ });
116+ const jiti = createJiti(pathToFileURL(path).href, {
117+ interopDefault: true,
118+ moduleCache: false,
119+ });
120+
121+ const fromDir = dirname(path);
122+ // require.resolve is anchored to the jsonc's directory so plugin
123+ // packages resolve from the host app's `node_modules` regardless of
124+ // CWD.
125+ const require = createRequire(
126+ isAbsolute(path) ? path : resolvePath(fromDir, "_anchor.js"),
127+ );
128+
129+ const loaded: AnyPlugin[] = [];
130+ for (const entry of entries) {
131+ const serverEntry = `${entry.package}/server`;
132+ const resolved = yield* Effect.try({
133+ try: () => require.resolve(serverEntry),
134+ catch: (cause) =>
135+ new LoadPluginsError({
136+ message:
137+ `[load-plugins] cannot resolve "${serverEntry}" from ${fromDir}. ` +
138+ `Is "${entry.package}" installed and does it export "./server"?`,
139+ cause,
140+ }),
141+ });
142+ const mod = (yield* Effect.tryPromise({
143+ try: () => jiti.import(resolved),
144+ catch: (cause) =>
145+ new LoadPluginsError({
146+ message: `[load-plugins] failed to import "${serverEntry}" from ${resolved}.`,
147+ cause,
148+ }),
149+ })) as
150+ | { default?: LooseConfiguredPlugin }
151+ | LooseConfiguredPlugin;
152+ const factory = (
153+ typeof mod === "function" ? mod : (mod.default ?? null)
154+ ) as LooseConfiguredPlugin | null;
155+ if (!factory || typeof factory !== "function") {
156+ return yield* new LoadPluginsError({
157+ message:
158+ `[load-plugins] "${serverEntry}" did not export a default ` +
159+ `definePlugin(...) factory.`,
160+ });
161+ }
162+ const merged = { ...(deps ?? {}), ...(entry.options ?? {}) };
163+ loaded.push(factory(merged));
120164 }
121− const merged = { ...(deps ?? {}), ...(entry.options ?? {}) };
122− loaded.push(factory(merged));
123− }
124165
125− return loaded;
126−};
166+ return loaded;
167+ });