firebaseプロジェクトでmonorepo構成（npm workspace）

一般的なfirebaseプロジェクトでのmonorepo構成を作ってみます。
いきなり全体を作らず、順に構成していきます。

以下の4つを達成したらゴールとします。

- [ ] 共通コードを読み込んだ状態でreactのローカル環境が動く
- [ ] firebase hostingのデプロイが完了し期待通りの出力結果
- [ ] 共通コードを読み込んだ状態でfirebase functionsのローカル環境が動く
- [ ] firebase functionsのデプロイが完了し期待通りの出力結果

## Reactのテンプレート作成

```
npx create-react-app my-monorepo --template typescript
```

作成すると以下の構成になります。

```
├── package-lock.json
├── package.json
├── src
│   └── App.tsx
└── tsconfig.json
```

## packages/commonを作成

```
npm init -w packages/common
```

とりあえず質問は全てEnterでOK。

```
.
├── package-lock.json
├── package.json
├── packages
│   └── common
│       └── package.json
├── src
│   └── App.tsx
└── tsconfig.json
```

## cracoでwebpackの設定をカスタマイズ

srcディレクトリ以外でTypeScriptファイルをJavaScriptにトランスパイルせずに読み込みたい場合、それを可能にするためにはwebpackの設定をカスタマイズします。そのためcracoを追加します。

```
npm i -D @craco/craco
touch craco.config.js
```

## 共通関数を作成

```
npm install -w packages/common date-fns
```

``` packages/common/index.ts
import { format } from 'date-fns'

export function formatDate(date: Date) {
  return format(date, 'yyyy-MM-dd HH:mm')
}
```

``` App.tsx
import { formatDate } from 'common'
// 省略
<p>現在の時間: {formatDate(new Date())}</p>
```

ここまでで1つゴールが達成できました。

- [x] 共通コードを読み込んだ状態でreactのローカル環境が動く

## firebase hostingにアップロード

ローカル環境では動くことが確認できたのでfirebase hostingにアップロードしてみます。

```
firebase init
```

デフォルトで選択をすすめると以下のような構成になります。

```
├── craco.config.js
├── firebase.json
├── functions
│   ├── package.json
│   ├── src
│   │   └── index.ts
│   └── tsconfig.json
├── package-lock.json
├── package.json
├── packages
│   └── common
│       ├── index.ts
│       └── package.json
├── src
│   └── App.tsx
└── tsconfig.json
```

## hostingのデプロイ用の設定

firebase.jsonをreact-create-appに合わせる。

``` firebase.json
  "hosting": {
    "public": "build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
```

package.jsonにスクリプトを追加して、

``` package.json
  "scripts": {
    "deploy:hosting": "npm run build && firebase deploy --only hosting"
  }
```

実行すればデプロイは完了します。

```
npm run deploy:hosting
```

- [x] firebase hostingのデプロイが完了し期待通りの出力結果

## functions側の設定

functionsをpackages/functionsに移動します。

```
├── craco.config.js
├── firebase.json
├── package-lock.json
├── package.json
├── packages
│   ├── common
│   │   ├── index.ts
│   │   └── package.json
│   └── functions
│       ├── package.json
│       ├── src
│       │   └── index.ts
│       └── tsconfig.json
├── src
│   └── App.tsx
└── tsconfig.json
```

``` diff:firebase.json
- "source": "functions",
+ "source": "packages/functions",
```

``` package.json
  "workspaces": [
    "packages/common",
    "packages/functions"
  ]
```

見通しをよくするため、今回使わないスクリプト、依存関係をpackage.jsonから一度削除します。

``` packages/functions/package.json
{
  "name": "functions",
  "scripts": {
    "build": "tsc",
    "serve": "npm run build && firebase emulators:start --only functions",
    "shell": "npm run build && firebase functions:shell",
    "deploy": "firebase deploy --only functions"
  },
  "engines": {
    "node": "16"
  },
  "main": "lib/index.js",
  "private": true
}
```

改めて必要なパッケージをpackages/functionsにインストールします。

```
npm install -w packages/functions firebase-admin firebase-functions
```

serve:functionsのスクリプトを追加します。

``` package.json
  "scripts": {
    "start": "craco start",
    ... 
    "deploy:hosting": "npm run build && firebase deploy --only hosting",
    "serve:functions": "npm run serve -w packages/functions"
  }
```

スクリプトを実行しAPIにアクセスすると、Hello from Firebase! が返ってきました。
とりあえずAPIが動くことは確認できました。

```
npm run serve:functions
```

## functionsでcommonをimport

以下のコードを書き実行すると、エラーが出ます。

``` packages/functions/index.ts
import * as functions from "firebase-functions";
import { formatDate } from "common"

export const helloWorld = functions.https.onRequest((request, response) => {
  functions.logger.info("Hello logs!", {structuredData: true});
  response.send("Hello from Firebase!" + formatDate(new Date()));
});
```

```
functions: Failed to load function definition from source: FirebaseError: Failed to load function definition from source: Failed to generate manifest from function source: Error: Cannot find module '/my-monorepo/node_modules/common/index.js'. Please verify that the package.json has a valid "main" entry
```

エラーメッセージの通り、現在packages/commonにindex.jsはありません。

```
└── packages
    └── common
        ├── index.ts
        └── package.json
```

commonのmainをdist/index.jsにしてtsconfig.jsonも追加しbuildできるようにします。

``` diff:packages/common/package.json
{
-  "main": "index.js"
+  "main": "dist/index.js"
}
```

``` packages/common/tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "outDir": "./dist",
  },
}
```

serveの前にcommonをbuildするようなスクリプトを追加します。（ここではpackはなくても動きますが、deploy時に必要なので追加しときます。）

``` package.json
  "scripts": {
    "prebuild:common": "cd packages/common && tsc && npm pack && mv *.tgz ../functions",
    "serve:functions": "npm run prebuild:common && npm run serve -w packages/functions",
  }
```

これで `npm run serve:functions` を実行すると先ほどのエラーは解消され動くようになりました。

- [x] 共通コードを読み込んだ状態でfirebase functionsのローカル環境が動く

## functionsをdeploy

今回のようなfunctionsの下にないローカルのcommonパッケージを使いたい場合はnpm packする必要があります。
以下のignoreの通り、node_modulesのファイルはdeployされないため、サーバー側でnpm ciをしてモジュールをインストールします。

``` firebase.json
  "functions": [
    {
      "source": "packages/functions",
      "codebase": "default",
      "ignore": [
        "node_modules",
        ".git",
        "firebase-debug.log",
        "firebase-debug.*.log"
      ],
    }
  ],
```

サーバー側でcommonパッケージをインストールするには
functions下にnpm packしたファイルを置き、package.jsonに`file:common-1.0.0.tgz`と指定することでサーバーがでもinstallが可能になります。

``` packages/functions/package.json
  "dependencies": {
    "common": "file:common-1.0.0.tgz",
```

predeployにprebuildとbuildコマンドを実行するようにして、`firebase deploy --only functions`を叩けば無事デプロイができました。

``` firebase.json
  "functions": [
    {
      "source": "packages/functions",
      "predeploy": [
        "npm run prebuild:common",
        "npm run build -w functions",
      ]
    }
  ],
```

期待した出力も確認できました。

- [x] firebase functionsのデプロイが完了し期待通りの出力結果

## predeploy, postdeploy

完成したかと思いきや、再度 `serve:functions` で実行するとpackages/commonのコードが反映されていませんでした。

node_modulesにdeploy時にインストールしたコードが残ったままになっているので、functionsではpackages/commonよりfunctions/node_modeusle/commonのコードが優先されてしまいます。

```
└── functions
    ├── common-1.0.0.tgz
    ├── firebase-debug.log
    ├── lib
    │   ├── index.js
    │   └── index.js.map
    ├── node_modules
    │   └── common
    │       ├── dist
    │       │   └── index.js
    │       ├── index.ts
    │       ├── package.json
    │       └── tsconfig.json
```

functionsのデプロイ時にのみpackとinstallは行えばよいので、firebase.jsonの`predeploy`と`postdeploy`を使って以下のように書き換えました。

```
"functions": [
  ...,
  {
    "predeploy": [
      "npm run build -w packages/common",
      "npm pack -w packages/common && mv *.tgz packages/functions",
      "cd packages/functions && npm i ./common-1.0.0.tgz",
      "npm run build -w functions"
    ],
    "postdeploy": [
      "npm -w packages/functions remove common",
      "echo 'deploy completed!!'"
    ]
  }
],
```


## その他

### npmの不具合

npm packとnpm install *.tgz したときにnpm 8.19 より下のバージョンではインストールができない問題がありました。
npmのバージョンを8.19.2にしたことで正常にインストールできるようになりました。

https://github.com/npm/cli/releases?expanded=true&page=1&q=v8.5.0

### npm packでdist以下のファイルが含まれない

.gitignoreに`**/dist/*`を書いていることでnpm packにdist/index.js以外が含まれませんでした。
空の.npmignoreファイルを追加することで回避できました。

## さいごに

Reactのコードもpackages/frontのようにpackages以下に移動するのが理想かと思いますが、とりあえずゴールは達成できたので今回はここまでとします。

最後の方は少し説明が雑になったので、詳細は以下のリポジトリをみていただければと思います。

https://github.com/matometaru/my-monorepo