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
26import { pathToFileURL } from "node:url";26import { pathToFileURL } from "node:url";
27import * as fs from "node:fs";27import * as fs from "node:fs";
28import * as jsonc from "jsonc-parser";28import * as jsonc from "jsonc-parser";
29import { Effect, Schema } from "effect";
29 30
30import type { AnyPlugin } from "@executor-js/sdk";31import type { AnyPlugin } from "@executor-js/sdk";
31 32
@@ -36,7 +37,15 @@ import type { AnyPlugin } from "@executor-js/sdk"; · 4 unmodified lines
36// across the runtime boundary.37// across the runtime boundary.
37type LooseConfiguredPlugin = (options?: Record<string, unknown>) => AnyPlugin;38type LooseConfiguredPlugin = (options?: Record<string, unknown>) => AnyPlugin;
38 39
39import type { PluginConfig } from "./schema";40import { ExecutorFileConfig } from "./schema";
41
42export class LoadPluginsError extends Schema.TaggedErrorClass<LoadPluginsError>()(
43 "LoadPluginsError",
44 {
45 message: Schema.String,
46 cause: Schema.optional(Schema.Unknown),
47 },
48) {}
40 49
41export interface LoadPluginsFromJsoncOptions {50export interface LoadPluginsFromJsoncOptions {
42 /** Absolute path to `executor.jsonc` (or compatible). */51 /** Absolute path to `executor.jsonc` (or compatible). */
@@ -57,70 +66,102 @@ export interface LoadPluginsFromJsoncOptions { · 14 unmodified lines
57 */66 */
58export const loadPluginsFromJsonc = async (67export const loadPluginsFromJsonc = async (
59 options: LoadPluginsFromJsoncOptions,68 options: LoadPluginsFromJsoncOptions,
60): Promise<readonly AnyPlugin[] | null> => {69): Promise<readonly AnyPlugin[] | null> =>
61 const { path, deps } = options;70 Effect.runPromise(loadPluginsFromJsoncEffect(options));
62 if (!fs.existsSync(path)) return null;
63 71
64 const raw = fs.readFileSync(path, "utf8");72const loadPluginsFromJsoncEffect = (
65 const errors: jsonc.ParseError[] = [];73 options: LoadPluginsFromJsoncOptions,
66 const parsed = jsonc.parse(raw, errors) as74): Effect.Effect<readonly AnyPlugin[] | null, LoadPluginsError> =>
67 | { plugins?: readonly PluginConfig[] }75 Effect.gen(function* () {
68 | undefined;76 const { path, deps } = options;
69 if (errors.length > 0) {77 if (!fs.existsSync(path)) return null;
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 }
75 78
76 const entries = parsed?.plugins ?? null;79 const raw = fs.readFileSync(path, "utf8");
77 if (!entries || entries.length === 0) return null;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 }
78 90
79 // jiti is created once per call; `moduleCache: false` ensures a91 const config = yield* Schema.decodeUnknownEffect(ExecutorFileConfig)(parsed).pipe(
80 // restart picks up freshly-installed packages without process restart92 Effect.mapError(
81 // (relevant when the dev server kicks a reload after `executor plugin93 (error) =>
82 // install`).94 new LoadPluginsError({
83 const { createJiti } = await import("jiti");95 message: `[load-plugins] failed to decode ${path}: ${error.issue.toString()}`,
84 const jiti = createJiti(pathToFileURL(path).href, {96 cause: error,
85 interopDefault: true,97 }),
86 moduleCache: false,98 ),
87 });99 );
88 100
89 const fromDir = dirname(path);101 const entries = config.plugins ?? null;
90 // require.resolve is anchored to the jsonc's directory so plugin102 if (!entries || entries.length === 0) return null;
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 );
96 103
97 const loaded: AnyPlugin[] = [];104 // jiti is created once per call; `moduleCache: false` ensures a
98 for (const entry of entries) {105 // restart picks up freshly-installed packages without process restart
99 const serverEntry = `${entry.package}/server`;106 // (relevant when the dev server kicks a reload after `executor plugin
100 let resolved: string;107 // install`).
101 try {108 const { createJiti } = yield* Effect.tryPromise({
102 resolved = require.resolve(serverEntry);109 try: () => import("jiti"),
103 } catch {110 catch: (cause) =>
104 throw new Error(111 new LoadPluginsError({
105 `[load-plugins] cannot resolve "${serverEntry}" from ${fromDir}. ` +112 message: `[load-plugins] failed to import jiti.`,
106 `Is "${entry.package}" installed and does it export "./server"?`,113 cause,
107 );114 }),
108 }115 });
109 const mod = (await jiti.import(resolved)) as116 const jiti = createJiti(pathToFileURL(path).href, {
110 | { default?: LooseConfiguredPlugin }117 interopDefault: true,
111 | LooseConfiguredPlugin;118 moduleCache: false,
112 const factory = (119 });
113 typeof mod === "function" ? mod : (mod.default ?? null)120
114 ) as LooseConfiguredPlugin | null;121 const fromDir = dirname(path);
115 if (!factory || typeof factory !== "function") {122 // require.resolve is anchored to the jsonc's directory so plugin
116 throw new Error(123 // packages resolve from the host app's `node_modules` regardless of
117 `[load-plugins] "${serverEntry}" did not export a default ` +124 // CWD.
118 `definePlugin(...) factory.`,125 const require = createRequire(
119 );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));
120 }164 }
121 const merged = { ...(deps ?? {}), ...(entry.options ?? {}) };
122 loaded.push(factory(merged));
123 }
124 165
125 return loaded;166 return loaded;
126};167 });