Unified · Split
| @@ -26,6 +26,7 @@ import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; · 25 unmodified lines | ||
| 26 | 26 | import { pathToFileURL } from "node:url"; |
| 27 | 27 | import * as fs from "node:fs"; |
| 28 | 28 | import * as jsonc from "jsonc-parser"; |
| 29 | +import { Effect, Schema } from "effect"; | |
| 29 | 30 | |
| 30 | 31 | import type { AnyPlugin } from "@executor-js/sdk"; |
| 31 | 32 | |
| @@ -36,7 +37,15 @@ import type { AnyPlugin } from "@executor-js/sdk"; · 4 unmodified lines | ||
| 36 | 37 | // across the runtime boundary. |
| 37 | 38 | type LooseConfiguredPlugin = (options?: Record<string, unknown>) => AnyPlugin; |
| 38 | 39 | |
| 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 | +) {} | |
| 40 | 49 | |
| 41 | 50 | export interface LoadPluginsFromJsoncOptions { |
| 42 | 51 | /** Absolute path to `executor.jsonc` (or compatible). */ |
| @@ -57,70 +66,102 @@ export interface LoadPluginsFromJsoncOptions { · 14 unmodified lines | ||
| 57 | 66 | */ |
| 58 | 67 | export const loadPluginsFromJsonc = async ( |
| 59 | 68 | 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)); | |
| 63 | 71 | |
| 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; | |
| 75 | 78 | |
| 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 | + } | |
| 78 | 90 | |
| 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 | + ); | |
| 88 | 100 | |
| 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; | |
| 96 | 103 | |
| 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)); | |
| 120 | 164 | } |
| 121 | − const merged = { ...(deps ?? {}), ...(entry.options ?? {}) }; | |
| 122 | − loaded.push(factory(merged)); | |
| 123 | − } | |
| 124 | 165 | |
| 125 | − return loaded; | |
| 126 | −}; | |
| 166 | + return loaded; | |
| 167 | + }); | |