Skip to content

Commit 12240bb

Browse files
zhouxinyongScriptedAlchemy2heal1
authored
feat(dts-plugin): support custom outputDir for DTS type emission (#4487)
Co-authored-by: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Co-authored-by: ScriptedAlchemy <zackaryjackson@bytedance.com> Co-authored-by: Hanric <hanric.zhang@gmail.com>
1 parent ec7ab53 commit 12240bb

File tree

7 files changed

+354
-7
lines changed

7 files changed

+354
-7
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@module-federation/dts-plugin': minor
3+
'@module-federation/sdk': minor
4+
---
5+
6+
feat(dts-plugin): support custom outputDir for DTS type emission
7+
8+
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.

apps/website-new/docs/en/configure/dts.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The `DtsRemoteOptions` types are as follows:
3131
interface DtsRemoteOptions {
3232
tsConfigPath?: string;
3333
typesFolder?: string;
34+
outputDir?: string;
3435
deleteTypesFolder?: boolean;
3536
additionalFilesToCompile?: string[];
3637
compilerInstance?: 'tsc' | 'vue-tsc';
@@ -109,6 +110,39 @@ Whether to throw an error when a problem is encountered during type generation
109110
> priority: dts.generateTypes.tsConfigPath > dts.tsConfigPath
110111
tsconfig configuration file path
111112

113+
#### outputDir
114+
115+
- Type: `string`
116+
- Required: No
117+
- Default value: `undefined`
118+
119+
Custom base output directory for generated type assets.
120+
121+
When this option is not set, Module Federation emits `@mf-types.zip` and
122+
`@mf-types.d.ts` relative to the bundler output directory. If your remote entry
123+
is emitted to a nested subdirectory such as `production/remoteEntry.js`, set
124+
`dts.generateTypes.outputDir` to the same nested output directory so the type
125+
artifacts are emitted alongside the entry file.
126+
127+
This keeps the default inferred type URLs aligned with the remote entry path, so
128+
consumers usually do not need `remoteTypeUrls` just because the remote entry is
129+
served from a subdirectory.
130+
131+
```ts title="module-federation.config.ts"
132+
new ModuleFederationPlugin({
133+
filename: 'production/remoteEntry.js',
134+
dts: {
135+
generateTypes: {
136+
outputDir: `dist/react/${process.env.DEPLOY_ENVIRONMENT || 'production'}`,
137+
},
138+
},
139+
});
140+
```
141+
142+
With the config above, the generated files are emitted to
143+
`dist/react/production/@mf-types.zip` and
144+
`dist/react/production/@mf-types.d.ts`.
145+
112146
#### typesFolder
113147

114148
- Type: `string`

apps/website-new/docs/zh/configure/dts.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface PluginDtsOptions {
3030
interface DtsRemoteOptions {
3131
tsConfigPath?: string;
3232
typesFolder?: string;
33+
outputDir?: string;
3334
deleteTypesFolder?: boolean;
3435
additionalFilesToCompile?: string[];
3536
compilerInstance?: 'tsc' | 'vue-tsc';
@@ -109,6 +110,39 @@ interface DtsRemoteOptions {
109110
110111
tsconfig 配置文件路径
111112

113+
#### outputDir
114+
115+
- 类型:`string`
116+
- 是否必填:否
117+
- 默认值:`undefined`
118+
119+
用于指定生成类型产物的基础输出目录。
120+
121+
未设置时,Module Federation 会相对于构建器的输出目录生成
122+
`@mf-types.zip``@mf-types.d.ts`。如果你的 remote entry 输出到了
123+
`production/remoteEntry.js` 这类嵌套子目录,建议将
124+
`dts.generateTypes.outputDir` 设置为相同的嵌套输出目录,这样类型产物会和
125+
entry 文件一起输出到同一个目录。
126+
127+
这样可以让默认推导出的类型文件地址与 remote entry 路径保持一致,因此仅仅
128+
因为 remote entry 部署在子目录中时,通常不再需要额外配置
129+
`remoteTypeUrls`
130+
131+
```ts title="module-federation.config.ts"
132+
new ModuleFederationPlugin({
133+
filename: 'production/remoteEntry.js',
134+
dts: {
135+
generateTypes: {
136+
outputDir: `dist/react/${process.env.DEPLOY_ENVIRONMENT || 'production'}`,
137+
},
138+
},
139+
});
140+
```
141+
142+
以上配置会将类型文件输出到
143+
`dist/react/production/@mf-types.zip`
144+
`dist/react/production/@mf-types.d.ts`
145+
112146
#### typesFolder
113147

114148
- 类型:`string`

packages/dts-plugin/src/core/configurations/remotePlugin.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,48 @@ describe('hostPlugin', () => {
160160
extractThirdParty: false,
161161
});
162162
});
163+
164+
it('custom outputDir changes outDir base path', () => {
165+
const tsConfigPath = join(__dirname, 'tsconfig.test.json');
166+
const customOutputDir = 'dist/react/production';
167+
const { tsConfig, remoteOptions } = retrieveRemoteConfig({
168+
moduleFederationConfig,
169+
tsConfigPath,
170+
outputDir: customOutputDir,
171+
});
172+
173+
expect(remoteOptions.outputDir).toBe(customOutputDir);
174+
expect(tsConfig.compilerOptions.outDir).toBe(
175+
resolve(
176+
remoteOptions.context,
177+
customOutputDir,
178+
'@mf-types',
179+
'compiled-types',
180+
),
181+
);
182+
});
183+
184+
it('custom outputDir combined with custom typesFolder', () => {
185+
const tsConfigPath = join(__dirname, 'tsconfig.test.json');
186+
const { tsConfig, remoteOptions } = retrieveRemoteConfig({
187+
moduleFederationConfig,
188+
tsConfigPath,
189+
outputDir: 'dist/react/staging',
190+
typesFolder: 'my-types',
191+
compiledTypesFolder: 'compiled',
192+
});
193+
194+
expect(remoteOptions.outputDir).toBe('dist/react/staging');
195+
expect(remoteOptions.typesFolder).toBe('my-types');
196+
expect(tsConfig.compilerOptions.outDir).toBe(
197+
resolve(
198+
remoteOptions.context,
199+
'dist/react/staging',
200+
'my-types',
201+
'compiled',
202+
),
203+
);
204+
});
163205
});
164206
});
165207
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import path from 'path';
2+
import { describe, expect, it } from 'vitest';
3+
import {
4+
isSafeRelativePath,
5+
normalizeGenerateTypesOptions,
6+
resolveEmitAssetName,
7+
} from './GenerateTypesPlugin';
8+
9+
describe('GenerateTypesPlugin', () => {
10+
const basePluginOptions = {
11+
name: 'testRemote',
12+
filename: 'remoteEntry.js',
13+
exposes: {
14+
'./button': './src/components/button',
15+
},
16+
shared: {},
17+
};
18+
19+
describe('normalizeGenerateTypesOptions', () => {
20+
it('should use compiler outputDir when user does not set outputDir', () => {
21+
const result = normalizeGenerateTypesOptions({
22+
context: '/project',
23+
outputDir: 'dist',
24+
dtsOptions: {
25+
generateTypes: {
26+
generateAPITypes: true,
27+
},
28+
consumeTypes: false,
29+
},
30+
pluginOptions: basePluginOptions,
31+
});
32+
33+
expect(result).toBeDefined();
34+
expect(result!.remote.outputDir).toBe('dist');
35+
});
36+
37+
it('should allow user outputDir to override compiler outputDir', () => {
38+
const result = normalizeGenerateTypesOptions({
39+
context: '/project',
40+
outputDir: 'dist',
41+
dtsOptions: {
42+
generateTypes: {
43+
generateAPITypes: true,
44+
outputDir: 'dist/production',
45+
},
46+
consumeTypes: false,
47+
},
48+
pluginOptions: basePluginOptions,
49+
});
50+
51+
expect(result).toBeDefined();
52+
expect(result!.remote.outputDir).toBe('dist/production');
53+
});
54+
55+
it('should return undefined when generateTypes is false', () => {
56+
const result = normalizeGenerateTypesOptions({
57+
context: '/project',
58+
outputDir: 'dist',
59+
dtsOptions: {
60+
generateTypes: false,
61+
consumeTypes: false,
62+
},
63+
pluginOptions: basePluginOptions,
64+
});
65+
66+
expect(result).toBeUndefined();
67+
});
68+
});
69+
70+
describe('asset emission path calculation', () => {
71+
// These tests verify the path.relative logic used in emitTypesFiles
72+
// to ensure correct asset names under various outputDir configurations
73+
74+
it('should compute relative zip path same as basename when outputDir matches compiler output', () => {
75+
const compilerOutputPath = path.resolve('/project', 'dist');
76+
const zipTypesPath = path.resolve('/project', 'dist', '@mf-types.zip');
77+
78+
const relZip = path.relative(compilerOutputPath, zipTypesPath);
79+
expect(relZip).toBe('@mf-types.zip');
80+
});
81+
82+
it('should compute relative zip path with subdirectory when custom outputDir is deeper', () => {
83+
const compilerOutputPath = path.resolve('/project', 'dist');
84+
const zipTypesPath = path.resolve(
85+
'/project',
86+
'dist',
87+
'production',
88+
'@mf-types.zip',
89+
);
90+
91+
const relZip = path.relative(compilerOutputPath, zipTypesPath);
92+
expect(relZip).toBe(path.join('production', '@mf-types.zip'));
93+
});
94+
95+
it('should compute relative api types path with subdirectory', () => {
96+
const compilerOutputPath = path.resolve('/project', 'dist/react');
97+
const apiTypesPath = path.resolve(
98+
'/project',
99+
'dist/react/staging',
100+
'@mf-types.d.ts',
101+
);
102+
103+
const relApi = path.relative(compilerOutputPath, apiTypesPath);
104+
expect(relApi).toBe(path.join('staging', '@mf-types.d.ts'));
105+
});
106+
107+
it('should fall back to basename when zip is outside compiler output (starts with ..)', () => {
108+
const compilerOutputPath = path.resolve('/project', 'dist');
109+
const zipTypesPath = path.resolve(
110+
'/other-project',
111+
'dist',
112+
'@mf-types.zip',
113+
);
114+
115+
const relZip = path.relative(compilerOutputPath, zipTypesPath);
116+
// When the relative path starts with '..', the plugin should fall back to basename
117+
expect(isSafeRelativePath(relZip)).toBe(false);
118+
119+
// Verify fallback behavior
120+
const emitZipName = resolveEmitAssetName({
121+
compilerOutputPath,
122+
assetPath: zipTypesPath,
123+
fallbackName: path.basename(zipTypesPath),
124+
});
125+
expect(emitZipName).toBe('@mf-types.zip');
126+
});
127+
128+
it('should handle nested deploy environment subdirectories', () => {
129+
// Simulates: webpack output = dist/react, entry at dist/react/staging/
130+
const compilerOutputPath = path.resolve('/project', 'dist/react');
131+
const customOutputDir = 'dist/react/staging';
132+
const zipTypesPath = path.resolve(
133+
'/project',
134+
customOutputDir,
135+
'@mf-types.zip',
136+
);
137+
138+
const relZip = path.relative(compilerOutputPath, zipTypesPath);
139+
expect(relZip).toBe(path.join('staging', '@mf-types.zip'));
140+
expect(relZip.startsWith('..')).toBe(false);
141+
});
142+
143+
it('should handle custom typesFolder with custom outputDir', () => {
144+
const compilerOutputPath = path.resolve('/project', 'dist');
145+
const zipTypesPath = path.resolve(
146+
'/project',
147+
'dist',
148+
'production',
149+
'my-types.zip',
150+
);
151+
152+
const relZip = path.relative(compilerOutputPath, zipTypesPath);
153+
expect(relZip).toBe(path.join('production', 'my-types.zip'));
154+
});
155+
156+
it('should treat windows cross-drive path as unsafe relative path', () => {
157+
const relZip = path.win32.relative(
158+
'C:\\dist',
159+
'D:\\types\\@mf-types.zip',
160+
);
161+
expect(relZip).toBe('D:\\types\\@mf-types.zip');
162+
expect(isSafeRelativePath(relZip)).toBe(false);
163+
});
164+
165+
it('should resolve relative asset name for nested output directory', () => {
166+
const emitZipName = resolveEmitAssetName({
167+
compilerOutputPath: path.resolve('/project', 'dist'),
168+
assetPath: path.resolve(
169+
'/project',
170+
'dist',
171+
'production',
172+
'@mf-types.zip',
173+
),
174+
fallbackName: '@mf-types.zip',
175+
});
176+
expect(emitZipName).toBe(path.join('production', '@mf-types.zip'));
177+
});
178+
});
179+
});

0 commit comments

Comments
 (0)