Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.
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',
),
);
});
});
});
});
150 changes: 150 additions & 0 deletions packages/dts-plugin/src/plugins/GenerateTypesPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import path from 'path';
import { describe, expect, it } from 'vitest';
import { normalizeGenerateTypesOptions } 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(relZip.startsWith('..')).toBe(true);

// Verify fallback behavior
const emitZipName = relZip.startsWith('..')
? path.basename(zipTypesPath)
: relZip;
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'));
});
});
});
38 changes: 31 additions & 7 deletions packages/dts-plugin/src/plugins/GenerateTypesPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,34 @@ 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.
let emitZipName = zipName;
let emitApiFileName = apiFileName;
if (zipTypesPath && compilerOutputPath) {
const relZip = path.relative(compilerOutputPath, zipTypesPath);
if (relZip && !relZip.startsWith('..')) {
emitZipName = relZip;
Comment thread
zhouxinyong marked this conversation as resolved.
Outdated
}
}
if (apiTypesPath && compilerOutputPath) {
const relApi = path.relative(compilerOutputPath, apiTypesPath);
if (relApi && !relApi.startsWith('..')) {
emitApiFileName = relApi;
}
}

if (isProd && emitZipName && compilation.getAsset(emitZipName)) {
callback();
return;
}
Expand All @@ -166,11 +187,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,
),
Expand All @@ -179,11 +200,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,
),
Expand All @@ -196,7 +217,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<void>((resolve, reject) => {
compiler.outputFileSystem.mkdir(
path.dirname(zipOutputPath),
Expand Down Expand Up @@ -228,7 +249,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<void>((resolve, reject) => {
compiler.outputFileSystem.mkdir(
path.dirname(apiOutputPath),
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/types/plugins/ModuleFederationPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,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;
Expand Down