// @stago/sdk - Vite Plugin for Module Configuration // Automatically generates module.json from stago.config.ts during build import type { Plugin, ResolvedConfig } from 'vite'; import { loadConfigFromFile } from 'vite'; import { resolve, join, isAbsolute } from 'path'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { validateModuleConfig, type ModuleConfig } from './module-config.js'; import { stagoMfSharedPackageNames } from './mf-shared.js'; import { nestVersionedDistCopy } from './dist-versioning.js'; export interface StagoConfigPluginOptions { /** * Path to Stago module config file, relative to Vite root. * @default "stago.config.ts" */ configFile?: string; /** * Fail build when config is missing/invalid. * @default true */ strict?: boolean; /** * Also nest a full copy of the build output into a version directory, for * CI/CD pipelines (e.g. 云效内置 OSS 上传组件) that always upload a fixed * local directory as-is to a fixed remote prefix, with no way to inject * the module version into the upload path per-run. * * - `true`: for app modules, nest into `//` (e.g. * `dist/1.2.0/`). Point the pipeline's local-directory setting straight * at `outDir` (e.g. `dist`). Note this uploads `outDir`'s own flat root * files (kept for npm packaging / the non-CDN `/module-assets` fallback) * *again* on every deploy, wasting bandwidth/storage — prefer the string * form below when `outDir` is also used for npm packaging. For api-only * modules, `true` is ignored with a warning because flat CDN output * requires an explicit subdirectory. * - a string, e.g. `'cdn'`: for app modules, nest into * `///` (e.g. `dist/cdn/1.2.0/`). For api-only * modules, copy flat into `//` with no version segment, * so each deployment overwrites the same CDN prefix. Point the pipeline's * local directory at `/` — a name fixed once at * pipeline-setup time, independent of the version — so only the CDN copy * ever lives there and nothing else gets uploaded. * Also auto-slims the npm pack to `module.json` + `api.js` (+ map); * browser assets (`remoteEntry.js`, `assets/`, …) stay on disk for the * CDN nest but are not published — requires MODULE_CDN_BASE_URL in * production (do not rely on `/module-assets` for such packages). * * The nested copy is automatically excluded from npm publishes (a * `.npmignore` is written at `outDir` root at build time) — no manual * `.npmignore`/`files` setup needed, and it works even when this * module's own `package.json` declares `"files": ["dist"]`. Server-only * artifacts (`api.js`) stay on the flat `outDir` for npm / the API host * and are omitted from the nest so they are not uploaded to public CDN/OSS. * @default false */ versionedDist?: boolean | string; } /** * Vite plugin that generates module.json from stago.config.ts * * @description Reads stago.config.ts during build, converts to module.json format, * and writes to dist directory. This ensures npm packages contain the module * configuration needed by the registration service. * * @example * ```typescript * // vite.config.ts * import { stagoConfigPlugin } from '@stago/sdk/vite' * * export default defineConfig({ * plugins: [ * stagoConfigPlugin() * ] * }) * ``` */ export function stagoConfigPlugin(options: StagoConfigPluginOptions = {}): Plugin { const configFileName = options.configFile || 'stago.config.ts'; const strict = options.strict ?? true; const versionedDistOption = options.versionedDist ?? false; const versionedDist = Boolean(versionedDistOption); const versionedDistSubdir = typeof versionedDistOption === 'string' ? versionedDistOption : undefined; let resolvedConfig: ResolvedConfig | null = null; const runCloseBundle = async (): Promise => { const rootDir = resolvedConfig?.root || process.cwd(); const outDir = resolvedConfig ? resolve(rootDir, resolvedConfig.build.outDir) : resolve(rootDir, 'dist'); const configPath = isAbsolute(configFileName) ? configFileName : resolve(rootDir, configFileName); const failOrWarn = (message: string, err?: unknown): void => { const details = err instanceof Error ? ` ${err.message}` : ''; const full = `[stago-config] ${message}${details}`; if (strict) { throw new Error(full); } console.warn(full); }; if (!existsSync(configPath)) { failOrWarn(`${configFileName} not found, skipping module.json generation`); return; } try { const loaded = await loadConfigFromFile( { command: 'build', mode: resolvedConfig?.mode || 'production', isSsrBuild: false, isPreview: false, }, configPath, rootDir, ); const stagoConfig = loaded?.config as | { name: string; version?: string; // Optional - auto-detected from package.json description?: string; author?: string; /** 'app' (默认,MF 前端模块) | 'api-only' (仅后端 API,前端独立部署) */ kind?: 'app' | 'api-only'; menu?: { title: string; icon?: string; order?: number; /** 1=侧栏顶级模块,2=归入 parent 分组 */ level?: number; parent?: string; children?: Array<{ key: string; title: string; icon?: string; path?: string }>; }; api?: { prefix?: string; handlers?: string }; client?: { logtoAppId: string }; } | undefined; if (!stagoConfig) { failOrWarn(`No default export found in ${configFileName}`); return; } // kind=app 时 menu 必填(前端模块需挂菜单);api-only 无需 menu/federation const kind = stagoConfig.kind ?? 'app'; if (kind === 'app' && !stagoConfig.menu?.title) { failOrWarn('kind="app" requires menu.title in stago.config.ts'); return; } if (kind === 'api-only' && !stagoConfig.api?.prefix) { failOrWarn('kind="api-only" requires api.prefix in stago.config.ts'); return; } // Auto-detect version from package.json if not specified in stago.config.ts. // When stago.config.ts *does* pin an explicit version, cross-check it against // package.json instead of trusting it blindly — the two are easy to let drift // (e.g. bumping package.json via `npm version` without remembering // stago.config.ts still has an old hardcoded value), and a stale version here // silently ships under the wrong version directory / registers the wrong // version with the registration service. let version = stagoConfig.version; const packageJsonPath = resolve(rootDir, 'package.json'); let packageJsonVersion: string | undefined; if (existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); packageJsonVersion = packageJson.version; } catch { failOrWarn('Failed to parse package.json for version'); return; } } if (!version) { if (!packageJsonVersion) { failOrWarn('No version in stago.config.ts and package.json not found'); return; } version = packageJsonVersion; console.log(`[stago-config] Auto-detected version from package.json: ${version}`); } else if (packageJsonVersion && packageJsonVersion !== version) { failOrWarn( `stago.config.ts version "${version}" does not match package.json version ` + `"${packageJsonVersion}". Remove the version field from stago.config.ts to ` + `auto-detect it from package.json instead.`, ); return; } // Convert StagoModuleConfig to ModuleConfig format, then validate. // api-only 模块只产 module.json + dist/api.js,不注入 federation/menu—— // 前端由业务方独立部署,不经底座 MF 加载。 const moduleConfig: ModuleConfig = kind === 'api-only' ? { name: stagoConfig.name, version: version, kind: 'api-only', description: stagoConfig.description || '', author: stagoConfig.author || '', api: { prefix: stagoConfig.api?.prefix || '', handlers: stagoConfig.api?.handlers, routes: undefined, }, client: stagoConfig.client ? { logtoAppId: stagoConfig.client.logtoAppId } : undefined, } : { name: stagoConfig.name, version: version, // Use auto-detected or explicit version description: stagoConfig.description || '', author: stagoConfig.author || '', menu: { title: stagoConfig.menu!.title, icon: stagoConfig.menu!.icon || 'AppstoreOutlined', level: stagoConfig.menu!.level ?? 1, parent: stagoConfig.menu!.parent ?? '', order: stagoConfig.menu!.order || 100, children: stagoConfig.menu!.children || undefined, }, federation: { exposes: ['./export-app'], remotes: {}, shared: [...stagoMfSharedPackageNames], }, api: stagoConfig.api ? { prefix: stagoConfig.api.prefix || '', handlers: stagoConfig.api.handlers, routes: undefined, } : undefined, }; // Validate configuration const validated = validateModuleConfig(moduleConfig); if (!validated) { failOrWarn('Invalid module configuration'); return; } // Write to dist/module.json if (!existsSync(outDir)) { mkdirSync(outDir, { recursive: true }); } const outputPath = join(outDir, 'module.json'); const outputContent = `${JSON.stringify(validated, null, 2)}\n`; const previousContent = existsSync(outputPath) ? readFileSync(outputPath, 'utf-8') : null; if (previousContent !== outputContent) { writeFileSync(outputPath, outputContent); } console.log(`[stago-config] Generated module.json: ${outputPath}`); console.log(`[stago-config] Module: ${validated.name}@${validated.version} (kind=${validated.kind})`); if (validated.kind === 'app' && validated.menu?.children?.length) { console.log( `[stago-config] Menu children: ${validated.menu.children.map(c => c.title).join(', ')}`, ); } if (versionedDist) { if (kind === 'api-only' && !versionedDistSubdir) { console.warn( '[stago-config] kind="api-only" ignores versionedDist: true; ' + 'use versionedDist: "cdn" for a flat CDN upload directory (no version subdirectory).', ); } else { const nestVersion = kind !== 'api-only'; try { nestVersionedDistCopy(outDir, version, versionedDistSubdir, { nestVersion }); const nestedPath = versionedDistSubdir && !nestVersion ? join(outDir, versionedDistSubdir) : versionedDistSubdir ? join(outDir, versionedDistSubdir, version) : join(outDir, version); console.log( nestVersion ? `[stago-config] Nested versioned copy: ${nestedPath}` : `[stago-config] Nested flat CDN copy: ${nestedPath}`, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn(`[stago-config] Failed to nest versioned copy: ${message}`); } } } } catch (err) { failOrWarn('Failed to generate module.json:', err); } }; return { name: 'stago-config-plugin', apply: 'build', configResolved(config: ResolvedConfig) { resolvedConfig = config; }, // closeBundle 是 Rollup 的 "async, parallel" hook:多个插件的 closeBundle // 几乎同时被触发,谁先 resolve 完全取决于各自异步耗时,enforce/plugins // 数组顺序只影响"谁先被调用",不影响"谁先完成"。 // versionedDist 需要等其它 closeBundle 把浏览器侧产物写盘后再嵌套副本 // (api.js 本身不会进 CDN nest,但仍可能有晚到的前端资产),因此启用时 // 用 { order: 'post', sequential: true },让 Rollup 在执行本插件前先 await // 所有已触发但未设置 sequential 的并行 closeBundle。 // 不启用该选项时用普通函数形式,行为与之前完全一致。 closeBundle: versionedDist ? { order: 'post' as const, sequential: true, handler: runCloseBundle } : runCloseBundle, }; }