Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/feat-dts-custom-output-dir.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions apps/website-new/docs/en/configure/dts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`
Expand Down
34 changes: 34 additions & 0 deletions apps/website-new/docs/zh/configure/dts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface PluginDtsOptions {
interface DtsRemoteOptions {
tsConfigPath?: string;
typesFolder?: string;
outputDir?: string;
deleteTypesFolder?: boolean;
additionalFilesToCompile?: string[];
compilerInstance?: 'tsc' | 'vue-tsc';
Expand Down Expand Up @@ -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`
Expand Down
42 changes: 42 additions & 0 deletions packages/dts-plugin/src/core/configurations/remotePlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
);
});
});
});
});
179 changes: 179 additions & 0 deletions packages/dts-plugin/src/plugins/GenerateTypesPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
});
Loading