Module Federation の Typescript についてメモ
Module Federation + Typescript の課題
Module Federation を使用する際の Typescript の型定義の共有方法について調べていたのだが、手動でゴニョゴニョセットアップする作業が必要な、言わば workaround レベルの解しか見つからず、”Best Practice” と言えるほどの解決策にまだ巡り会えていない...
よりスマートに、簡潔に型定義を Module Federation で共有したいので調べていく。
その調査の過程をスクラップに記録しておく。
How typesafe can a remote be with Typescript?
こちらの issue を読み進める
各 module の型を作ってから project 全体でまとめて共有
What I have done is use node to generate a .d.ts file, that are loaded by our global.d.ts file.
So each Remote outputs a .d.ts file that go declare module "dlaWidgets/Widets" {}, with using the ts.createProgram you can tap into the typeChecker stuff that i use to print its public api. Kinda like getting tsc to ouput definition files, but instead spit them into a single file.
Then each of my hosts import this .d.ts file, which I have registered in a global.d.ts, that all of my projectReferenced monorepo apps include.
So there is a small disconnect between a webpack config, and the types. But any exposed things, are automagically available with types in my entire app. So ts might say its a valid import, but just need to actially configure it with webpack also.
Working on open sourcing this generative thing - just gotta make sure it works in all use cases.
tsconfig の path-mapping を利用
Depending on your use case/setup you could solve this by having a tsconfig.json in the project root and leverage path-mapping to resolve the apps entry points (Remote Name + Exposed module):
注意点
You got to be careful with that approach though. It does mean that you cannot also use path mappings for other things in your app, because if you are you're probably also using tsconfig paths webpack resolver, in which case module federation plugin can't "remote" those. You can but you'd have to have a special build-time tsconfig that excludes the MF mappings.
各 remote app の types を node_modules 下に生成
This command finds and reads the package's federation.config.json and uses the TS compiler to generate types with a similar process to what @maraisr described - however I write the types into an @types/__federated_types directory under the projects node_modules directory. This is nice because there is no extra resolution configuration needed in the tsconfig file or webpack config files since they look in @types as a default location.
The command does allow for specifying where the federated typing files should be written, but by default, it writes to the node_modules/@types/__federated_types directory.
各 remote app で .d.ts 手動で作って、host app の tsconfig で include
I was able to get my project with module federation and typescript working, along with typesafety of remote components, with minimal cost:
Each remote application has a declaration.d.ts file in the root defining the props of exposed components:
Parent application has the following entry in tsconfig.json:
Cons
in this scenario the lazy-loaded components are still reporting missing/incorrect props passed. The only con I can see here is that you need to make sure every newly exposed component is added to the .d.ts file, but that's a minimal cost. Also, make sure you've set "module": "esnext" in compilerOptions in your tsconfig.json - setting "commonjs" will result in console error reg. shared module not being available.
EDIT: One more (cosmetic) issue is, that VSCode's built-in TS server doesn't seem to recognize the "include" part of tsconfig.json resulting in marking the dynamic import in parent app as error (Cannot find module 'app2/App' or its corresponding type declarations) but the build works fine. If anyone knows a fix for this, I'd be ever so grateful.
各 remote app が型定義を tar でまとめる => host app で解凍して利用
For the host which exposes modules
- I created a dts-loader which will emit and collect the .d.ts file. And also it generates the entry .d.ts file based on the exposes.
- Then I create a tarball(.tgz file) for the emitted types & entries. And I deployed this tarball with the application's statics, for example, to http://localhost:9000/app-dts.tgz
For the host which requires remotes
- I created a webpack plugin WebpackRemoteTypesPlugin which will download and unpack the tarball from remote automatically when running webpack.
Also, I was able to automate the archiving by using tar-webpack-plugin, so my types are generated and synced whenever I npm start - pretty neat!
"各 remote app が型定義を tar でまとめる => host app で解凍して利用" を試す
For the host which exposes modules
- I created a dts-loader which will emit and collect the .d.ts file. And also it generates the entry .d.ts file based on the exposes.
- Then I create a tarball(.tgz file) for the emitted types & entries. And I deployed this tarball with the application's statics, for example, to http://localhost:9000/app-dts.tgz
For the host which requires remotes
- I created a webpack plugin WebpackRemoteTypesPlugin which will download and unpack the tarball from remote automatically when running webpack.
こちらの対応が良さそうなので試していく
Project setup
$ mkdir mf-ts-sample
$ cd mf-ts-sample
host app 作成
$ pnpx create-mf-app
+ create-mf-app 1.0.15
? Pick the name of your app: host
? Project Type: Application
? Port number: 3000
? Framework: react
? Language: typescript
? CSS: Tailwind
Your 'host' project is ready to go.
Next steps:
▶️ cd host
▶️ npm install
▶️ npm start
remote app 作成
$ pnpx create-mf-app
+ create-mf-app 1.0.15
? Pick the name of your app: remote
? Project Type: Application
? Port number: 3001
? Framework: react
? Language: typescript
? CSS: Tailwind
Your 'remote' project is ready to go.
Next steps:
▶️ cd remote
▶️ npm install
▶️ npm start
project structure
├── host
│ ├── package.json
│ ├── postcss.config.js
│ ├── src
│ │ ├── App.tsx
│ │ ├── index.html
│ │ ├── index.scss
│ │ └── index.ts
│ ├── tailwind.config.js
│ └── webpack.config.js
└── remote
├── package.json
├── postcss.config.js
├── src
│ ├── App.tsx
│ ├── index.html
│ ├── index.scss
│ └── index.ts
├── tailwind.config.js
└── webpack.config.js
Module Federation Setup
remote app から Greeting
component を export し、host app から利用できるようにする。
Greeting
component 作成
import React from 'react';
type Props = {
name: string;
};
export const Greeting = ({ name }: Props) => {
return <div>{`Hello, ${name} !!!`}</div>;
};
Greeting
component export
remtoe app から Greeting
component を export するよう webpack.config.js を更新
new ModuleFederationPlugin({
name: 'remote',
filename: 'remoteEntry.js',
remotes: {},
exposes: {
+ './Greeting': './src/Greeting.tsx',
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
},
}),
host app 側から import
Greeting
component を host app で使用できるように webpack.config.js を更新
new ModuleFederationPlugin({
name: 'host',
filename: 'remoteEntry.js',
remotes: {
+ remote: 'remote@http://localhost:3001/remoteEntry.js',
},
exposes: {},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
},
}),
Greeting
component を使用
host app で import React from 'react';
import ReactDOM from 'react-dom';
import { Greeting } from 'remote/Greeting';
import './index.scss';
const App = () => (
<div className='p-5 mx-auto max-w-6xl space-y-5'>
<h1 className='text-3xl'>host app</h1>
<div className='flex gap-2'>
Greeting:
<Greeting name='John' />
</div>
</div>
);
ReactDOM.render(<App />, document.getElementById('app'));
何が問題なのか
dev server を立ち上げて動くには動くが、VSCode では typescript が怒っている。
Module Federation を利用して import している remote/Greeting
に注目↓
Cannot find module 'remote/Greeting' or its corresponding type declarations.ts(2307)
おなじみの "型定義がありませんよ" warning が出ている。
また、本来なら Greeting
component は name
prop が required なため、型エラーを出してもらわなければ困るわけだが、さも問題ないように振る舞っている↓
remte app の型定義を用意する
dts-loader を使って remote app から export される component の d.ts を作成する。
install
yarn add -D dts-loader
webpack.config.js 編集
rules: [
// other rules config...
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [
{
loader: 'dts-loader',
options: {
name: 'remote', // The name configured in ModuleFederationPlugin
exposes: {
// The exposes configured in ModuleFederationPlugin
'./Greeting': './src/Greeting.tsx',
},
typesOutputDir: '.wp_federation', // Optional, default is '.wp_federation'
},
},
],
},
],
build してみる
$ yarn build
エラー出た
ERROR in ./src/Greeting.tsx
Module build failed (from ./node_modules/dts-loader/lib/index.js):
Error: Could not find a valid 'tsconfig.json'.
tsconfig.json
がなかった... (create-mf-app で scaffoldiing すると tsconfig は作られない)
tsconfig.json
用意
{
"compilerOptions": {
"target": "es2016",
"jsx": "react-jsx",
"module": "ES2015",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
build すると .wp_federation
(webpack.config.js で設定可能) に d.ts ファイルが生成される。
生成された .wp_federation/remote
directory をまるっと、host app 側の node_modules/@types
下にコピペすれば、型は正しく動く。
Greeting component で正しく型エラーが出るようになった
このコピペ作業を自動化できればいいのかな...?
type を共有
型をまとめる
先ほど作成した d.ts を tar にまとめる
tar czvf ./.wp_federation/remote-dts.tgz -C ./.wp_federation remote
home app から読み込む
remote app で型をまとめた tar を home app から取得し、解凍して取り込む。
この一連の処理を WebpackRemoteTypesPlugin を利用して実現してみる。
yarn add -D webpack-remote-types-plugin
const WebpackRemoteTypesPlugin = require('webpack-remote-types-plugin').default;
module.exports = {
// other config ...
plugins: [
// other plugin config...
new WebpackRemoteTypesPlugin({
remotes: {
remote: 'remote@http://localhost:3001/',
},
outputDir: 'node_modules/@types', // supports [name] as the remote name
remoteFileName: '[name]-dts.tgz', // default filename is [name]-dts.tgz where [name] is the remote name, for example, `app` with the above setup
}),
],
};
http://localhost:3001/
で起動している "remote" という名前の module から 'remote-dts.tgz' という名前の tar file をダウンロードして、node_modules/@types
directory に解凍したものを配置する
ということを設定している。
別途の設定なく、解凍した d.ts を typescript にチェックしてもらいたいので、今回は直接 node_modules/@types
に置いてみる。
(運用上の不都合があるかも....)
(WebpackRemoteTypesPlugin の使用例では、任意の directory ("types" dir) を指定し、tsconfig.json の paths で読み込ませる設定になっていた)
remote app の devServer 修正
home app から tar にアクセスできるように、remote app 側の devServer の config を修正し、http://localhost:3001/remote-dts.tgz
で tar にアクセスできるようにする。
// other config ....
devServer: {
port: 3001,
historyApiFallback: true,
static: ['.wp_federation'],
},
.wp_federation dir の中身を静的ファイルとして serve するよう設定
(remote-dts.tgz は .wp_federation 下に生成されるように設定している)
tar の生成を自動化したい
現状を整理すると、
- remote app dev server 起動 (=> d.ts 生成)
- tar 生成 (手動でコマンド)
- home app dev server 起動 (=> tar 取得 => 解凍して node_modules/@types 下に配置)
という流れまではできた。
2 の tar 生成を自動化できれば良さげ。
tar-webpack-plugin を使ってみる
install
yarn add -D tar-webpack-plugin
webpack.config.js 編集
const TarWebpackPlugin = require('tar-webpack-plugin').default;
module.exports = {
// other config ...
plugins: [
// other plugin config ...
new TarWebpackPlugin({
action: 'c',
file: '.wp_federation/remote-dts.tgz',
cwd: '.wp_federation',
fileList: ['remote'],
}), ]
}
これで dev serverを起動時に .wp_federation/remote
(d.ts が置いてある directory) が tar にまとめられる。やってることは以下のコマンドと一緒。
tar czvf ./.wp_federation/remote-dts.tgz -C ./.wp_federation remote
まとめ
一通り型の情報を共有するところまでできたのでまとめる
やってること
- remote app から export しているコードの d.ts を生成
- 1 で生成した d.ts を tar にまとめる
- 2 で生成した tar を serve (今回は local の dev server)
- host app から 3 で公開されている tar をダウンロード
- 4 で取得した tar を解凍し d.ts を取得 => 型の情報GET !
一連の動作
remote app 側
dev server を起動
$ webpack serve --open --mode development
自動的に d.ts がまとめられた tar が生成される↓
host app 側
$ webpack serve --open --mode development
自動的に node_modules/@types/remote
が生成される↓
remote/Greeting
の型の情報がちゃんと読み込まれて、型エラーが出てることがわかる↓
所感
一度 tar にまとめて serve しているので、poly-repo (複数の repository に分かれてる) 環境にも対応可能なのは良い。
ただ手動で型を準備する手間は省けるとはいえ、設定項目がまだ多いかな...
monorepo だったら、もっと手順省略できたりするのか...?
それと、host app 側で tar を取得して d.ts を解凍する処理は dev server 起動時にのみ行われるので、remote app 側で型が更新された場合でも dev server が起動し続ける限り host app 側には反映されない。外部の型情報が変わるたびに host app 側の dev server を再起動しなければならないのも改善の余地はあると感じた。
しかし、開発時に頻繁に外部の interface が変わるシチュエーションってそうあるのか...? そもそも Module の境界を適切に定められていれば、そんなこともない気もするな。運用してみて確かめるしかないか。
整理された記事をありがとうございます。
しかし、質問があります。
サーバーをローカルで実行する場合、型エラーは発生せずに正常に動作しますが、構築すると型エラーが発生します。この問題がどのように解決されるのか興味があります。