Open16

Module Federation の Typescript についてメモ

nbstshnbstsh

Module Federation + Typescript の課題

Module Federation を使用する際の Typescript の型定義の共有方法について調べていたのだが、手動でゴニョゴニョセットアップする作業が必要な、言わば workaround レベルの解しか見つからず、”Best Practice” と言えるほどの解決策にまだ巡り会えていない...

よりスマートに、簡潔に型定義を Module Federation で共有したいので調べていく。

その調査の過程をスクラップに記録しておく。

nbstshnbstsh

How typesafe can a remote be with Typescript?

こちらの issue を読み進める

https://github.com/module-federation/module-federation-examples/issues/20

nbstshnbstsh

各 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.

https://github.com/module-federation/module-federation-examples/issues/20#issuecomment-625598734

nbstshnbstsh

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):

https://github.com/module-federation/module-federation-examples/issues/20#issuecomment-644930018

https://github.com/rangle/federated-modules-typescript

注意点

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.

nbstshnbstsh

各 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.

https://github.com/module-federation/module-federation-examples/issues/20#issuecomment-720216053

https://github.com/pixability/federated-types

nbstshnbstsh

各 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:

https://github.com/module-federation/module-federation-examples/issues/20#issuecomment-824774471

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.

nbstshnbstsh

各 remote app が型定義を tar でまとめる => host app で解凍して利用

For the host which exposes modules

  1. 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.
  2. 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

  1. I created a webpack plugin WebpackRemoteTypesPlugin which will download and unpack the tarball from remote automatically when running webpack.

https://github.com/module-federation/module-federation-examples/issues/20#issuecomment-847645348

https://github.com/ruanyl/dts-loader


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!

https://github.com/module-federation/module-federation-examples/issues/20#issuecomment-962138968

https://github.com/cjh804263197/tar-webpack-plugin

nbstshnbstsh

"各 remote app が型定義を tar でまとめる => host app で解凍して利用" を試す

For the host which exposes modules

  1. 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.
  2. 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

  1. I created a webpack plugin WebpackRemoteTypesPlugin which will download and unpack the tarball from remote automatically when running webpack.

https://github.com/module-federation/module-federation-examples/issues/20#issuecomment-847645348

こちらの対応が良さそうなので試していく

nbstshnbstsh

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
nbstshnbstsh

Module Federation Setup

remote app から Greeting component を export し、host app から利用できるようにする。

Greeting component 作成

remote/src/Greeting.tsx
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 を更新

remote/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 を更新

host/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'],
        },
      },
    }),

host app で Greeting component を使用

host/src/App.tsx
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'));

nbstshnbstsh

何が問題なのか

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 なため、型エラーを出してもらわなければ困るわけだが、さも問題ないように振る舞っている↓

nbstshnbstsh

remte app の型定義を用意する

dts-loader を使って remote app から export される component の d.ts を作成する。

https://github.com/ruanyl/dts-loader

install

yarn add -D dts-loader

webpack.config.js 編集

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 用意

remote/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 で正しく型エラーが出るようになった

このコピペ作業を自動化できればいいのかな...?

nbstshnbstsh

type を共有

型をまとめる

先ほど作成した d.ts を tar にまとめる

tar czvf ./.wp_federation/remote-dts.tgz -C ./.wp_federation remote

home app から読み込む

remote app で型をまとめた tar を home app から取得し、解凍して取り込む。

この一連の処理を WebpackRemoteTypesPlugin を利用して実現してみる。

https://github.com/ruanyl/webpack-remote-types-plugin

yarn add -D webpack-remote-types-plugin
host/webpack.config.js
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 にアクセスできるようにする。

remote/webpack.config.js
  // other config ....
  devServer: {
    port: 3001,
    historyApiFallback: true,
    static: ['.wp_federation'],
  },

.wp_federation dir の中身を静的ファイルとして serve するよう設定
(remote-dts.tgz は .wp_federation 下に生成されるように設定している)

tar の生成を自動化したい

現状を整理すると、

  1. remote app dev server 起動 (=> d.ts 生成)
  2. tar 生成 (手動でコマンド)
  3. home app dev server 起動 (=> tar 取得 => 解凍して node_modules/@types 下に配置)

という流れまではできた。
2 の tar 生成を自動化できれば良さげ。

tar-webpack-plugin を使ってみる

https://github.com/cjh804263197/tar-webpack-plugin

install

yarn add -D tar-webpack-plugin

webpack.config.js 編集

remote/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
nbstshnbstsh

まとめ

一通り型の情報を共有するところまでできたのでまとめる

やってること

  1. remote app から export しているコードの d.ts を生成
  2. 1 で生成した d.ts を tar にまとめる
  3. 2 で生成した tar を serve (今回は local の dev server)
  4. host app から 3 で公開されている tar をダウンロード
  5. 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 の型の情報がちゃんと読み込まれて、型エラーが出てることがわかる↓

nbstshnbstsh

所感

一度 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 の境界を適切に定められていれば、そんなこともない気もするな。運用してみて確かめるしかないか。

yoong_dyyoong_dy

整理された記事をありがとうございます。

しかし、質問があります。

サーバーをローカルで実行する場合、型エラーは発生せずに正常に動作しますが、構築すると型エラーが発生します。この問題がどのように解決されるのか興味があります。