// --------------------------------------------------------------------------- // @executor-js/vite-plugin — wires plugin client bundles into the host's // Vite build so plugin-contributed pages, widgets, and slot components // are present in the frontend bundle. The host imports // `virtual:executor/plugins-client` and gets the list of every loaded // plugin's `defineClientPlugin(...)` value. // // Two sources, concatenated and de-duplicated: // 1. `executor.config.ts` — static, TS-typed plugin tuple. The plugin // Vite-plugin reads each spec's `packageName` and emits an import // for `${packageName}/client`. // 2. `executor.jsonc#plugins` — dynamic, jiti-loaded at server boot. // The Vite plugin reads each entry's `package` field directly // (no need to import the server module — packageName == entry.package). // // Plugins without a `packageName` are SDK-only and contribute nothing to // the frontend bundle — they're skipped. // // HMR: the virtual module is part of Vite's graph. Changing either // `executor.config.ts` or `executor.jsonc` invalidates it and triggers // a hot update for plugin-list consumers; adding/removing a plugin // requires a Vite restart (because the npm dep graph changed). // --------------------------------------------------------------------------- import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; import { pathToFileURL } from "node:url"; import * as jsonc from "jsonc-parser"; import type { Plugin } from "vite"; import type { ExecutorCliConfig } from "@executor-js/sdk"; const VIRTUAL_ID = "virtual:executor/plugins-client"; const RESOLVED_ID = `\0${VIRTUAL_ID}`; const DEFAULT_CONFIG_CANDIDATES = [ "executor.config.ts", "executor.config.js", "executor.config.mjs", "src/executor.config.ts", "src/executor.config.js", ]; const DEFAULT_JSONC_CANDIDATES = ["executor.jsonc", "executor.json"]; const readJsoncPlugins = (path: string): readonly string[] => { try { const raw = readFileSync(path, "utf8"); const parsed = jsonc.parse(raw) as | { plugins?: ReadonlyArray<{ package?: string }> } | undefined; const entries = parsed?.plugins ?? []; return entries.map((e) => e.package).filter((p): p is string => !!p); } catch { return []; } }; const tryResolveClient = (packageName: string, fromDir: string): string | null => { const require = createRequire(resolvePath(fromDir, "_anchor.js")); try { return require.resolve(`${packageName}/client`); } catch { return null; } }; interface ExecutorVitePluginOptions { /** * Path to the executor config file. Resolved relative to the Vite * project root if not absolute. Defaults to the first match of * `executor.config.ts` / `.js` / `.mjs` (with a fallback under * `src/`). */ readonly configPath?: string; /** * Path to the executor jsonc manifest. Resolved relative to the Vite * project root if not absolute. Defaults to `executor.jsonc`. */ readonly jsoncPath?: string; } export default function executorVitePlugin(options: ExecutorVitePluginOptions = {}): Plugin { let projectRoot: string = process.cwd(); let resolvedConfigPath: string | null = null; let resolvedJsoncPath: string | null = null; let cachedSource: string | null = null; const resolveConfigPath = (): string | null => { if (resolvedConfigPath) return resolvedConfigPath; const candidates = options.configPath ? [options.configPath] : DEFAULT_CONFIG_CANDIDATES; for (const candidate of candidates) { const abs = isAbsolute(candidate) ? candidate : resolvePath(projectRoot, candidate); if (existsSync(abs)) { resolvedConfigPath = abs; return abs; } } return null; }; const resolveJsoncPath = (): string | null => { if (resolvedJsoncPath) return resolvedJsoncPath; const candidates = options.jsoncPath ? [options.jsoncPath] : DEFAULT_JSONC_CANDIDATES; for (const candidate of candidates) { const abs = isAbsolute(candidate) ? candidate : resolvePath(projectRoot, candidate); if (existsSync(abs)) { resolvedJsoncPath = abs; return abs; } } return null; }; const loadVirtualSource = async (): Promise => { if (cachedSource !== null) return cachedSource; const configPath = resolveConfigPath(); const jsoncPath = resolveJsoncPath(); const fromDir = configPath ? dirname(configPath) : projectRoot; // Collect plugin entries in priority order: static config first, // jsonc second. De-duplicate by package name — if both list the // same plugin, only one import is emitted. (Static wins for the // ordering of `plugins` array, which matters for nav order.) // // `clientConfig` only flows in via the static config path: it // lives on the runtime `PluginSpec` returned by // `definePlugin(...)`, which is only available when we jiti-load // the TS module. The jsonc path carries package names only. const entries: Array<{ readonly pkg: string; readonly clientConfig?: unknown }> = []; const seen = new Set(); if (configPath) { // jiti is a dev dep of consumers; importing dynamically lets the // plugin be lazy-loaded and avoids a hard requirement when the // host doesn't actually use plugins yet. const { createJiti } = await import("jiti"); const jiti = createJiti(pathToFileURL(configPath).href, { interopDefault: true, moduleCache: false, }); const mod = (await jiti.import(configPath)) as | { default?: ExecutorCliConfig } | ExecutorCliConfig; const config = ("default" in mod && mod.default ? mod.default : mod) as ExecutorCliConfig; for (const spec of config.plugins()) { if (!spec.packageName) continue; if (seen.has(spec.packageName)) continue; seen.add(spec.packageName); const clientConfig = (spec as { clientConfig?: unknown }).clientConfig; entries.push({ pkg: spec.packageName, ...(clientConfig !== undefined ? { clientConfig } : {}), }); } } if (jsoncPath) { for (const pkg of readJsoncPlugins(jsoncPath)) { if (seen.has(pkg)) continue; seen.add(pkg); entries.push({ pkg }); } } const lines: string[] = []; const exportExpressions: string[] = []; for (const entry of entries) { const resolved = tryResolveClient(entry.pkg, fromDir); if (!resolved) { // package was listed but didn't resolve. Likely culprits: // a typo, a package that hasn't published `./client` in its // exports map yet, or the package isn't installed. Warn // loudly so the dev sees their plugin's UI is missing instead // of silently shipping a host without it. console.warn( `[@executor-js/vite-plugin] plugin package "${entry.pkg}" listed but ` + `${entry.pkg}/client could not be resolved from ${fromDir}. The ` + `plugin's UI will not be bundled. Check that the package is ` + `installed and exports a \`./client\` subpath in its ` + `package.json.`, ); continue; } const ident = `__executor_plugin_${exportExpressions.length}`; // Emit the absolute file path rather than `${pkg}/client`. Vite // resolves bare specifiers from its `root`, which for hosts like // apps/local is `packages/app/` — a location that doesn't see // plugin packages installed under the consumer's node_modules. // Resolving here (from the consumer's executor.config dir) and // emitting the absolute path bypasses Vite's node_modules walk // entirely while still honoring each plugin's `exports./client` // conditions (Node's resolver handles those above). lines.push(`import ${ident} from ${JSON.stringify(pathToFileURL(resolved).href)};`); // Plugins that surface a `clientConfig` default-export a factory // `(config?) => ClientPluginSpec`; everyone else default-exports // a bare `ClientPluginSpec` value. We emit a call only when we // have config to thread through, so existing plugins need no // changes to keep working. exportExpressions.push( entry.clientConfig !== undefined ? `${ident}(${JSON.stringify(entry.clientConfig)})` : ident, ); } cachedSource = `${lines.join("\n")}\n` + `export const plugins = [${exportExpressions.join(", ")}];\n`; return cachedSource; }; return { name: "@executor-js/vite-plugin", enforce: "pre", configResolved(config) { projectRoot = config.root; }, resolveId(id) { if (id === VIRTUAL_ID) return RESOLVED_ID; return undefined; }, async load(id) { if (id !== RESOLVED_ID) return undefined; return loadVirtualSource(); }, handleHotUpdate(ctx) { const configPath = resolveConfigPath(); const jsoncPath = resolveJsoncPath(); const isWatched = (configPath && ctx.file === configPath) || (jsoncPath && ctx.file === jsoncPath); if (!isWatched) return undefined; cachedSource = null; const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID); return mod ? [mod] : undefined; }, }; } // Consumers wanting strong typing for `virtual:executor/plugins-client` // should add the following to a `vite-env.d.ts` (or any ambient `.d.ts`): // // declare module "virtual:executor/plugins-client" { // import type { ClientPluginSpec } from "@executor-js/sdk/client"; // export const plugins: readonly ClientPluginSpec[]; // } // // We don't ship the augmentation from this package because TS module // augmentation can only target modules TS already resolves, and Vite // virtual ids aren't resolvable by the type checker on their own.