// @stago/sdk - Module Configuration Types and Validation // Provides Zod-based validation for module.json configuration import { z } from 'zod'; // ========== Zod Schemas ========== /** * Name schema: @scope/name format */ const NameSchema = z .string() .min(3, 'Name must be at least 3 characters') .max(100, 'Name must not exceed 100 characters') .regex( /^@[a-z0-9-]+\/[a-z0-9-]+$/, 'Name must be in @scope/name format (e.g., @demo/sales-dashboard)', ); /** * Version schema: semver format */ const VersionSchema = z .string() .regex( /^\d+\.\d+\.\d+(-[a-z0-9]+(\.[a-z0-9]+)*)?(\+[a-z0-9]+(\.[a-z0-9]+)*)?$/, 'Version must be semver format (e.g., 1.0.0, 2.1.0-beta.1)', ); /** 模块内嵌套菜单项(支持任意层级 children) */ export type MenuItemConfigInput = { key: string; title: string; icon?: string; path?: string; children?: MenuItemConfigInput[]; }; const MenuItemSchema: z.ZodType = z.lazy(() => z .object({ key: z .string() .min(1, 'Menu item key is required') .max(50, 'Menu item key must not exceed 50 characters'), title: z .string() .min(1, 'Menu item title is required') .max(50, 'Menu item title must not exceed 50 characters'), icon: z.string().max(50, 'Menu item icon must not exceed 50 characters').optional(), // 叶子 path 可选:未填时 Host 可从 key 后缀推导(兼容旧 module.json) path: z.string().max(100, 'Menu item path must not exceed 100 characters').optional(), children: z.array(MenuItemSchema).optional(), }) .strict(), ); /** * Menu configuration schema */ const MenuSchema = z .object({ title: z .string() .min(1, 'Menu title is required') .max(50, 'Menu title must not exceed 50 characters'), icon: z.string().max(50, 'Icon name must not exceed 50 characters').default('AppstoreOutlined'), level: z .number() .int() .min(1, 'Level must be at least 1') .max(3, 'Level must not exceed 3') .default(1), parent: z.string().max(100, 'Parent path must not exceed 100 characters').default(''), order: z.number().int().min(0, 'Order must be at least 0').default(100), children: z.array(MenuItemSchema).optional(), }) .strict(); /** * Shared dependency schema - supports both array and object formats * Array format: ["react", "react-dom"] (simple) * Object format: { react: { singleton: true } } (advanced) */ const SharedSchema = z.union([ z.array(z.string().min(1, 'Shared dependency name cannot be empty')), z.record( z.string(), z .object({ singleton: z.boolean().default(true), requiredVersion: z.string().optional(), eager: z.boolean().default(false), }) .strict(), ), ]); /** * Federation configuration schema */ const FederationSchema = z .object({ exposes: z .array( z .string() .regex( /^\.\/[A-Za-z][A-Za-z0-9-]*$/, 'Expose must start with ./ followed by a valid identifier (e.g., ./export-app)', ), ) .min(1, 'At least one expose is required'), remotes: z .record( z.string(), z .object({ requiredVersion: z .string() .regex( /^(\^|~|>=|>|<=|<)?\d+\.\d+\.\d+/, 'Required version must be a valid version range', ), }) .strict(), ) .default({}), shared: SharedSchema.default(['react', 'react-dom']), }) .strict(); /** * API configuration schema */ const ApiSchema = z .object({ routes: z .record( z.string(), z .object({ methods: z .array(z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])) .min(1, 'At least one method is required'), handler: z.string().optional(), }) .strict(), ) .optional(), prefix: z .string() .regex( /^\/[a-z0-9-]*$/, 'Prefix must start with / and contain only lowercase letters, numbers, or hyphens', ) .default(''), handlers: z.string().max(100, 'Handlers path must not exceed 100 characters').optional(), }) .strict(); /** * 前端 client 配置(api-only 模块的业务前端在 logto 注册的 Application 配置)。 * 仅 api-only 模块需要;app 模块前端由底座 MF 加载,无需此字段。 */ const ClientSchema = z .object({ logtoAppId: z .string() .min(1, 'client.logtoAppId is required and must not be empty') .max(200, 'client.logtoAppId must not exceed 200 characters'), }) .strict(); /** * Data binding configuration schema */ const DataBindingSchema = z .object({ table: z .string() .max(50, 'Table name must not exceed 50 characters') .regex(/^[a-z_][a-z0-9_]*$/, 'Table name must be valid SQL identifier'), schema: z .record( z.string(), z .object({ type: z.enum(['string', 'number', 'boolean', 'date', 'json']), required: z.boolean().default(false), unique: z.boolean().default(false), label: z.string().max(50).optional(), }) .strict(), ) .optional(), permissions: z.array(z.enum(['read', 'write', 'admin'])).default(['read']), }) .strict(); /** * Module kind schema. * - `app`: 前后端一体模块(默认)。必须提供 `menu` 与 `federation`,由底座以 * Module Federation 远程加载前端。 * - `api-only`: 仅托管后端 API 的模块。无前端、无菜单、无 federation,前端由 * 业务方独立部署与访问,不经过云台页面。必须提供 `api.prefix`。 */ const KindSchema = z.enum(['app', 'api-only']); /** * Full module configuration schema * * `menu` 与 `federation` 在基础对象层声明为可选,再由 `superRefine` 按 `kind` * 条件强制:`app` 必填两者,`api-only` 必填 `api`。不用 `discriminatedUnion` * 是因为省略 `kind` 时需要回退到默认 `app` 以保持向后兼容,而判别联合在缺省 * 判别字段时无法命中任何变体。 */ const ModuleConfigSchema = z .object({ name: NameSchema, version: VersionSchema, kind: KindSchema.default('app'), description: z .string() .max(500, 'Description must not exceed 500 characters') .optional() .default(''), author: z.string().max(100, 'Author must not exceed 100 characters').optional().default(''), menu: MenuSchema.optional(), federation: FederationSchema.optional(), api: ApiSchema.optional(), client: ClientSchema.optional(), dataBinding: DataBindingSchema.optional(), }) .strict() .superRefine((data, ctx) => { if (data.kind === 'app') { if (!data.menu) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'menu is required when kind="app"', path: ['menu'], }); } if (!data.federation) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'federation is required when kind="app"', path: ['federation'], }); } } else if (data.kind === 'api-only') { if (!data.api) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'api is required when kind="api-only"', path: ['api'], }); } if (!data.client?.logtoAppId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'client.logtoAppId is required when kind="api-only"', path: ['client', 'logtoAppId'], }); } } }); // ========== Exported Types ========== export type ModuleKind = z.infer; export type ModuleConfig = z.input; export type ValidatedModuleConfig = z.output; export type MenuConfig = z.input; export type ValidatedMenuConfig = z.output; export type MenuItemConfig = z.input; export type ValidatedMenuItemConfig = z.output; export type FederationConfig = z.input; export type ValidatedFederationConfig = z.output; export type ApiConfig = z.input; export type ValidatedApiConfig = z.output; export type DataBindingConfig = z.input; export type ValidatedDataBindingConfig = z.output; // ========== Validation Function ========== /** * Validate module configuration * * @param config - Raw module configuration object * @returns ModuleConfig if valid, null if invalid * * @example * ```typescript * import { validateModuleConfig } from '@stago/sdk' * * const config = { * name: '@demo/sales-dashboard', * version: '1.0.0', * menu: { title: 'Sales Dashboard' }, * federation: { exposes: ['./export-app'] } * } * * const result = validateModuleConfig(config) * if (result) { * console.log('Valid config:', result) * } else { * console.error('Invalid configuration') * } * ``` */ export function validateModuleConfig(config: unknown): ValidatedModuleConfig | null { const result = ModuleConfigSchema.safeParse(config); return result.success ? result.data : null; } /** * Get the Zod schema for module configuration * Useful for advanced validation scenarios */ export { ModuleConfigSchema }; // ========== Module Definition ========== /** * Menu item for children configuration */ export interface ModuleMenuItem { /** Menu item key (unique identifier) */ key: string; /** Menu item title */ title: string; /** Menu item icon (Ant Design icon name) */ icon?: string; /** Module internal route path (required on leaf items) */ path?: string; /** Nested sub-menu items (Host sidebar grouping) */ children?: ModuleMenuItem[]; } /** * Menu configuration for module */ export interface ModuleMenu { /** Menu title */ title: string; /** Menu icon (Ant Design icon name) */ icon?: string; /** Menu order (sort order) */ order?: number; /** * 在底座侧栏的挂载层级(写入发布物 module.json,供注册服务使用)。 * - `1`:顶级模块项(默认) * - `2`:整模块挂在 `parent` 指定的分组下 * 与 `children`(模块内子页面,如 /、/list)含义不同,勿混用。 */ level?: number; /** * 当 `level` 为 2 时:父分组显示名(与顶级模块的 `title` 无继承关系)。 */ parent?: string; /** Sub-menu items */ children?: ModuleMenuItem[]; } /** * Shared dependency configuration */ export interface SharedDependency { /** Singleton - share single instance */ singleton?: boolean; /** Required version range */ requiredVersion?: string; /** Eager load */ eager?: boolean; } /** * API configuration for module */ export interface ModuleApi { /** API prefix (e.g., '/tasks') */ prefix?: string; /** API handlers directory (e.g., './src/api') */ handlers?: string; } /** * 前端 client 配置(api-only 模块) */ export interface ModuleClient { /** 该模块前端在 logto 注册的 Application App ID */ logtoAppId: string; } /** * Stago Module Configuration * Simplified configuration for stago.config.ts */ export interface StagoModuleConfig { /** Module name in @scope/name format */ name: string; /** Semantic version - optional, auto-detected from package.json during build */ version?: string; /** Module description */ description?: string; /** Author name */ author?: string; /** * 模块类型: * - `app`(默认):前后端一体模块,需提供 `menu`,由底座 MF 加载前端 * - `api-only`:仅托管后端 API,前端独立部署,不进底座页面,需提供 `api.prefix` */ kind?: ModuleKind; /** Menu configuration (required when `kind="app"`,api-only 时省略) */ menu?: ModuleMenu; /** API configuration (api-only 时必填) */ api?: ModuleApi; /** 前端 client 配置(api-only 时必填) */ client?: ModuleClient; /** Additional shared dependencies (optional) */ shared?: Record; } /** * Define Module Configuration * * @description Main function to define module in stago.config.ts. * Simplifies configuration - SDK handles Module Federation internals. * * `kind="app"`(默认)需提供 `menu`,构建产物经 Module Federation 由底座加载前端; * `kind="api-only"` 仅托管后端 API,需提供 `api.prefix`,前端由业务方独立部署, * 不出现在底座菜单与页面中。 * * @param config - Module configuration * @returns Validated configuration * * @example api-only 模块(前端独立部署) * ```typescript * // stago.config.ts * import { defineModule } from '@stago/sdk' * * export default defineModule({ * name: '@demo/report-api', * version: '1.0.0', * kind: 'api-only', * api: { * prefix: '/reports', * handlers: './src/api' * } * }) * ``` */ export function defineModule(config: StagoModuleConfig): StagoModuleConfig { // Validate name format if (!isValidModuleName(config.name)) { throw new Error(`Invalid module name: ${config.name}. Must be in @scope/name format`); } // Validate version format (optional - auto-detected from package.json during build) if (config.version && !isValidModuleVersion(config.version)) { throw new Error(`Invalid version: ${config.version}. Must be semver format`); } const kind: ModuleKind = config.kind ?? 'app'; if (kind !== 'app' && kind !== 'api-only') { throw new Error(`Invalid kind: ${String(config.kind)}. Must be "app" or "api-only"`); } if (kind === 'api-only') { if (!config.api?.prefix) { throw new Error('api.prefix is required when kind="api-only"'); } return { ...config, kind }; } // kind === 'app':menu 必填 if (!config.menu?.title) { throw new Error('menu.title is required when kind="app"'); } const menuLevel = config.menu.level; if (menuLevel !== undefined && (menuLevel < 1 || menuLevel > 3 || !Number.isInteger(menuLevel))) { throw new Error('menu.level must be an integer between 1 and 3'); } return { ...config, kind, menu: { ...config.menu, icon: config.menu.icon || 'AppstoreOutlined', order: config.menu.order || 100, level: menuLevel ?? 1, parent: config.menu.parent ?? '', }, }; } // ========== Helper Functions ========== /** * Create a minimal valid module configuration */ export function createMinimalModuleConfig( name: string, version: string, title: string, ): ModuleConfig { return { name, version, menu: { title }, federation: { exposes: ['./export-app'], }, }; } /** * Check if a name is valid @scope/name format */ export function isValidModuleName(name: string): boolean { return NameSchema.safeParse(name).success; } /** * Check if a version is valid semver format */ export function isValidModuleVersion(version: string): boolean { return VersionSchema.safeParse(version).success; }