diff --git a/.changeset/feat-dts-custom-output-dir.md b/.changeset/feat-dts-custom-output-dir.md new file mode 100644 index 00000000000..a7f76ef9de5 --- /dev/null +++ b/.changeset/feat-dts-custom-output-dir.md @@ -0,0 +1,8 @@ +--- +'@module-federation/dts-plugin': minor +'@module-federation/sdk': minor +--- + +feat(dts-plugin): support custom outputDir for DTS type emission + +Expose the `outputDir` option in `DtsRemoteOptions` so users can configure where `@mf-types.zip` and `@mf-types.d.ts` are emitted. Fix `GenerateTypesPlugin` to use `path.relative()` for correct asset placement in subdirectories. diff --git a/apps/website-new/docs/en/configure/dts.mdx b/apps/website-new/docs/en/configure/dts.mdx index 578fc097ac9..0442b64e9a4 100644 --- a/apps/website-new/docs/en/configure/dts.mdx +++ b/apps/website-new/docs/en/configure/dts.mdx @@ -31,6 +31,7 @@ The `DtsRemoteOptions` types are as follows: interface DtsRemoteOptions { tsConfigPath?: string; typesFolder?: string; + outputDir?: string; deleteTypesFolder?: boolean; additionalFilesToCompile?: string[]; compilerInstance?: 'tsc' | 'vue-tsc'; @@ -109,6 +110,39 @@ Whether to throw an error when a problem is encountered during type generation > priority: dts.generateTypes.tsConfigPath > dts.tsConfigPath tsconfig configuration file path +#### outputDir + +- Type: `string` +- Required: No +- Default value: `undefined` + +Custom base output directory for generated type assets. + +When this option is not set, Module Federation emits `@mf-types.zip` and +`@mf-types.d.ts` relative to the bundler output directory. If your remote entry +is emitted to a nested subdirectory such as `production/remoteEntry.js`, set +`dts.generateTypes.outputDir` to the same nested output directory so the type +artifacts are emitted alongside the entry file. + +This keeps the default inferred type URLs aligned with the remote entry path, so +consumers usually do not need `remoteTypeUrls` just because the remote entry is +served from a subdirectory. + +```ts title="module-federation.config.ts" +new ModuleFederationPlugin({ + filename: 'production/remoteEntry.js', + dts: { + generateTypes: { + outputDir: `dist/react/${process.env.DEPLOY_ENVIRONMENT || 'production'}`, + }, + }, +}); +``` + +With the config above, the generated files are emitted to +`dist/react/production/@mf-types.zip` and +`dist/react/production/@mf-types.d.ts`. + #### typesFolder - Type: `string` diff --git a/apps/website-new/docs/zh/configure/dts.mdx b/apps/website-new/docs/zh/configure/dts.mdx index 27b598205b8..c315885dcfa 100644 --- a/apps/website-new/docs/zh/configure/dts.mdx +++ b/apps/website-new/docs/zh/configure/dts.mdx @@ -30,6 +30,7 @@ interface PluginDtsOptions { interface DtsRemoteOptions { tsConfigPath?: string; typesFolder?: string; + outputDir?: string; deleteTypesFolder?: boolean; additionalFilesToCompile?: string[]; compilerInstance?: 'tsc' | 'vue-tsc'; @@ -109,6 +110,39 @@ interface DtsRemoteOptions { tsconfig 配置文件路径 +#### outputDir + +- 类型:`string` +- 是否必填:否 +- 默认值:`undefined` + +用于指定生成类型产物的基础输出目录。 + +未设置时,Module Federation 会相对于构建器的输出目录生成 +`@mf-types.zip` 和 `@mf-types.d.ts`。如果你的 remote entry 输出到了 +`production/remoteEntry.js` 这类嵌套子目录,建议将 +`dts.generateTypes.outputDir` 设置为相同的嵌套输出目录,这样类型产物会和 +entry 文件一起输出到同一个目录。 + +这样可以让默认推导出的类型文件地址与 remote entry 路径保持一致,因此仅仅 +因为 remote entry 部署在子目录中时,通常不再需要额外配置 +`remoteTypeUrls`。 + +```ts title="module-federation.config.ts" +new ModuleFederationPlugin({ + filename: 'production/remoteEntry.js', + dts: { + generateTypes: { + outputDir: `dist/react/${process.env.DEPLOY_ENVIRONMENT || 'production'}`, + }, + }, +}); +``` + +以上配置会将类型文件输出到 +`dist/react/production/@mf-types.zip` 和 +`dist/react/production/@mf-types.d.ts`。 + #### typesFolder - 类型:`string` diff --git a/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts b/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts index 8dd6c358e51..1d40df135f1 100644 --- a/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts +++ b/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts @@ -160,6 +160,48 @@ describe('hostPlugin', () => { extractThirdParty: false, }); }); + + it('custom outputDir changes outDir base path', () => { + const tsConfigPath = join(__dirname, 'tsconfig.test.json'); + const customOutputDir = 'dist/react/production'; + const { tsConfig, remoteOptions } = retrieveRemoteConfig({ + moduleFederationConfig, + tsConfigPath, + outputDir: customOutputDir, + }); + + expect(remoteOptions.outputDir).toBe(customOutputDir); + expect(tsConfig.compilerOptions.outDir).toBe( + resolve( + remoteOptions.context, + customOutputDir, + '@mf-types', + 'compiled-types', + ), + ); + }); + + it('custom outputDir combined with custom typesFolder', () => { + const tsConfigPath = join(__dirname, 'tsconfig.test.json'); + const { tsConfig, remoteOptions } = retrieveRemoteConfig({ + moduleFederationConfig, + tsConfigPath, + outputDir: 'dist/react/staging', + typesFolder: 'my-types', + compiledTypesFolder: 'compiled', + }); + + expect(remoteOptions.outputDir).toBe('dist/react/staging'); + expect(remoteOptions.typesFolder).toBe('my-types'); + expect(tsConfig.compilerOptions.outDir).toBe( + resolve( + remoteOptions.context, + 'dist/react/staging', + 'my-types', + 'compiled', + ), + ); + }); }); }); }); diff --git a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.test.ts b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.test.ts new file mode 100644 index 00000000000..56f818584a7 --- /dev/null +++ b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.test.ts @@ -0,0 +1,179 @@ +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { + isSafeRelativePath, + normalizeGenerateTypesOptions, + resolveEmitAssetName, +} from './GenerateTypesPlugin'; + +describe('GenerateTypesPlugin', () => { + const basePluginOptions = { + name: 'testRemote', + filename: 'remoteEntry.js', + exposes: { + './button': './src/components/button', + }, + shared: {}, + }; + + describe('normalizeGenerateTypesOptions', () => { + it('should use compiler outputDir when user does not set outputDir', () => { + const result = normalizeGenerateTypesOptions({ + context: '/project', + outputDir: 'dist', + dtsOptions: { + generateTypes: { + generateAPITypes: true, + }, + consumeTypes: false, + }, + pluginOptions: basePluginOptions, + }); + + expect(result).toBeDefined(); + expect(result!.remote.outputDir).toBe('dist'); + }); + + it('should allow user outputDir to override compiler outputDir', () => { + const result = normalizeGenerateTypesOptions({ + context: '/project', + outputDir: 'dist', + dtsOptions: { + generateTypes: { + generateAPITypes: true, + outputDir: 'dist/production', + }, + consumeTypes: false, + }, + pluginOptions: basePluginOptions, + }); + + expect(result).toBeDefined(); + expect(result!.remote.outputDir).toBe('dist/production'); + }); + + it('should return undefined when generateTypes is false', () => { + const result = normalizeGenerateTypesOptions({ + context: '/project', + outputDir: 'dist', + dtsOptions: { + generateTypes: false, + consumeTypes: false, + }, + pluginOptions: basePluginOptions, + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('asset emission path calculation', () => { + // These tests verify the path.relative logic used in emitTypesFiles + // to ensure correct asset names under various outputDir configurations + + it('should compute relative zip path same as basename when outputDir matches compiler output', () => { + const compilerOutputPath = path.resolve('/project', 'dist'); + const zipTypesPath = path.resolve('/project', 'dist', '@mf-types.zip'); + + const relZip = path.relative(compilerOutputPath, zipTypesPath); + expect(relZip).toBe('@mf-types.zip'); + }); + + it('should compute relative zip path with subdirectory when custom outputDir is deeper', () => { + const compilerOutputPath = path.resolve('/project', 'dist'); + const zipTypesPath = path.resolve( + '/project', + 'dist', + 'production', + '@mf-types.zip', + ); + + const relZip = path.relative(compilerOutputPath, zipTypesPath); + expect(relZip).toBe(path.join('production', '@mf-types.zip')); + }); + + it('should compute relative api types path with subdirectory', () => { + const compilerOutputPath = path.resolve('/project', 'dist/react'); + const apiTypesPath = path.resolve( + '/project', + 'dist/react/staging', + '@mf-types.d.ts', + ); + + const relApi = path.relative(compilerOutputPath, apiTypesPath); + expect(relApi).toBe(path.join('staging', '@mf-types.d.ts')); + }); + + it('should fall back to basename when zip is outside compiler output (starts with ..)', () => { + const compilerOutputPath = path.resolve('/project', 'dist'); + const zipTypesPath = path.resolve( + '/other-project', + 'dist', + '@mf-types.zip', + ); + + const relZip = path.relative(compilerOutputPath, zipTypesPath); + // When the relative path starts with '..', the plugin should fall back to basename + expect(isSafeRelativePath(relZip)).toBe(false); + + // Verify fallback behavior + const emitZipName = resolveEmitAssetName({ + compilerOutputPath, + assetPath: zipTypesPath, + fallbackName: path.basename(zipTypesPath), + }); + expect(emitZipName).toBe('@mf-types.zip'); + }); + + it('should handle nested deploy environment subdirectories', () => { + // Simulates: webpack output = dist/react, entry at dist/react/staging/ + const compilerOutputPath = path.resolve('/project', 'dist/react'); + const customOutputDir = 'dist/react/staging'; + const zipTypesPath = path.resolve( + '/project', + customOutputDir, + '@mf-types.zip', + ); + + const relZip = path.relative(compilerOutputPath, zipTypesPath); + expect(relZip).toBe(path.join('staging', '@mf-types.zip')); + expect(relZip.startsWith('..')).toBe(false); + }); + + it('should handle custom typesFolder with custom outputDir', () => { + const compilerOutputPath = path.resolve('/project', 'dist'); + const zipTypesPath = path.resolve( + '/project', + 'dist', + 'production', + 'my-types.zip', + ); + + const relZip = path.relative(compilerOutputPath, zipTypesPath); + expect(relZip).toBe(path.join('production', 'my-types.zip')); + }); + + it('should treat windows cross-drive path as unsafe relative path', () => { + const relZip = path.win32.relative( + 'C:\\dist', + 'D:\\types\\@mf-types.zip', + ); + expect(relZip).toBe('D:\\types\\@mf-types.zip'); + expect(isSafeRelativePath(relZip)).toBe(false); + }); + + it('should resolve relative asset name for nested output directory', () => { + const emitZipName = resolveEmitAssetName({ + compilerOutputPath: path.resolve('/project', 'dist'), + assetPath: path.resolve( + '/project', + 'dist', + 'production', + '@mf-types.zip', + ), + fallbackName: '@mf-types.zip', + }); + expect(emitZipName).toBe(path.join('production', '@mf-types.zip')); + }); + }); +}); diff --git a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts index d103436ff60..4d11ff6462f 100644 --- a/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts +++ b/packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts @@ -105,6 +105,34 @@ export const generateTypesAPI = ({ return fn(dtsManagerOptions); }; +const WINDOWS_ABSOLUTE_PATH_REGEXP = /^[a-zA-Z]:[\\/]/; + +export const isSafeRelativePath = (relativePath: string) => { + return ( + Boolean(relativePath) && + !relativePath.startsWith('..') && + !path.isAbsolute(relativePath) && + !WINDOWS_ABSOLUTE_PATH_REGEXP.test(relativePath) + ); +}; + +export const resolveEmitAssetName = ({ + compilerOutputPath, + assetPath, + fallbackName, +}: { + compilerOutputPath: string; + assetPath: string | undefined; + fallbackName: string | undefined; +}) => { + if (!assetPath) { + return fallbackName; + } + + const relativePath = path.relative(compilerOutputPath, assetPath); + return isSafeRelativePath(relativePath) ? relativePath : fallbackName; +}; + export class GenerateTypesPlugin implements WebpackPluginInstance { pluginOptions: moduleFederationPlugin.ModuleFederationPluginOptions; dtsOptions: moduleFederationPlugin.PluginDtsOptions; @@ -148,13 +176,30 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { const isProd = !isDev(); + // Resolve compiler output path for computing relative asset names + const compilerOutputPath = path.resolve(context, outputDir); + const emitTypesFiles = async (compilation: Compilation) => { // Dev types will be generated by DevPlugin, the archive filename usually is dist/.dev-server.zip try { const { zipTypesPath, apiTypesPath, zipName, apiFileName } = retrieveTypesAssetsInfo(dtsManagerOptions.remote); - if (isProd && zipName && compilation.getAsset(zipName)) { + // Compute asset names relative to compiler output path. + // When user sets a custom outputDir, the zip/api files may be in a subdirectory + // of the compiler output, so we need the relative path for correct asset emission. + const emitZipName = resolveEmitAssetName({ + compilerOutputPath, + assetPath: zipTypesPath, + fallbackName: zipName, + }); + const emitApiFileName = resolveEmitAssetName({ + compilerOutputPath, + assetPath: apiTypesPath, + fallbackName: apiFileName, + }); + + if (isProd && emitZipName && compilation.getAsset(emitZipName)) { callback(); return; } @@ -166,11 +211,11 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { if (isProd) { if ( zipTypesPath && - !compilation.getAsset(zipName) && + !compilation.getAsset(emitZipName) && fs.existsSync(zipTypesPath) ) { compilation.emitAsset( - zipName, + emitZipName, new compiler.webpack.sources.RawSource( fs.readFileSync(zipTypesPath) as unknown as string, ), @@ -179,11 +224,11 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { if ( apiTypesPath && - !compilation.getAsset(apiFileName) && + !compilation.getAsset(emitApiFileName) && fs.existsSync(apiTypesPath) ) { compilation.emitAsset( - apiFileName, + emitApiFileName, new compiler.webpack.sources.RawSource( fs.readFileSync(apiTypesPath) as unknown as string, ), @@ -196,7 +241,7 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { }; if (zipTypesPath && fs.existsSync(zipTypesPath)) { const zipContent = fs.readFileSync(zipTypesPath); - const zipOutputPath = path.join(compiler.outputPath, zipName); + const zipOutputPath = path.join(compiler.outputPath, emitZipName); await new Promise((resolve, reject) => { compiler.outputFileSystem.mkdir( path.dirname(zipOutputPath), @@ -228,7 +273,10 @@ export class GenerateTypesPlugin implements WebpackPluginInstance { if (apiTypesPath && fs.existsSync(apiTypesPath)) { const apiContent = fs.readFileSync(apiTypesPath); - const apiOutputPath = path.join(compiler.outputPath, apiFileName); + const apiOutputPath = path.join( + compiler.outputPath, + emitApiFileName, + ); await new Promise((resolve, reject) => { compiler.outputFileSystem.mkdir( path.dirname(apiOutputPath), diff --git a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts index b79516ec9e2..1b5bfc08a5b 100644 --- a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts +++ b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts @@ -383,6 +383,8 @@ export interface DtsRemoteOptions { tsConfigPath?: string; typesFolder?: string; compiledTypesFolder?: string; + /** Custom base output directory for generated types. When set, types will be emitted to this directory instead of the default compiler output directory. */ + outputDir?: string; deleteTypesFolder?: boolean; additionalFilesToCompile?: string[]; compileInChildProcess?: boolean;