Unified · Split
| @@ -26,6 +26,7 @@ import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; · 25 unmodified lines | |||
| 26 | import { pathToFileURL } from "node:url"; | 26 | import { pathToFileURL } from "node:url"; |
| 27 | import * as fs from "node:fs"; | 27 | import * as fs from "node:fs"; |
| 28 | import * as jsonc from "jsonc-parser"; | 28 | import * as jsonc from "jsonc-parser"; |
| 29 | import { Effect, Schema } from "effect"; | ||
| 29 | 30 | ||
| 30 | import type { AnyPlugin } from "@executor-js/sdk"; | 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 | // across the runtime boundary. | 37 | // across the runtime boundary. |
| 37 | type LooseConfiguredPlugin = (options?: Record<string, unknown>) => AnyPlugin; | 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 | export interface LoadPluginsFromJsoncOptions { | 50 | export 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 | */ |
| 58 | export const loadPluginsFromJsonc = async ( | 67 | export 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"); | 72 | const loadPluginsFromJsoncEffect = ( |
| 65 | const errors: jsonc.ParseError[] = []; | 73 | options: LoadPluginsFromJsoncOptions, |
| 66 | const parsed = jsonc.parse(raw, errors) as | 74 | ): 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 a | 91 | const config = yield* Schema.decodeUnknownEffect(ExecutorFileConfig)(parsed).pipe( |
| 80 | // restart picks up freshly-installed packages without process restart | 92 | Effect.mapError( |
| 81 | // (relevant when the dev server kicks a reload after `executor plugin | 93 | (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 plugin | 102 | 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)) as | 116 | 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 | }); |