😎

Next.jsとNest.js、prisma、firebase、cfでmonorepoやるにはtranspile力が足らんかった。

2022/09/25に公開約5,500字

開発環境の改善っていいですよね。
だって、当たるか当たらないかわからない試作をやるよりかは、確実に開発者の金と時間とメンタル消耗力を改善できるのですから。

前職では、サービスにgolang(echo, gorm)とtypescript、php(laravel、symfony、maple)とか使っていたので、monorepoにしても旨味が感じられなかったので踏み切らなかった。

ただ、個人でやる分には自由なので全てをTypeScriptにしてmonorepoで開発環境を整えてみました。

おれおれmonorepoから始まった

相対パス運用

まず初めてみたのが、相対パスによる異なるpackage.jsonにあるファイルを参照させてました。
参照先がtsファイルであろうと、開発環境では上手くトランスパイルしてくれて動いてました。

問題が生じたのは、production buildしたときでした。当然package.jsonが配置しているところがprojectのrootになるのでその外にあるtsファイルなどはbuildしてくれずにエラーになりました。。

一瞬で相対パスをやめました。

npm workspace運用

npmのworkspaceは、yarnやlernaと同じように指定したディレクトリをnode_modules以下にsymbolic linkをはって、対象のコードを呼び出す機能です。

npmのプライベートパッケージを運用するよりは次の点でお手軽です。

  • npm publishしなくて良い
  • npmrcやyarnrcなどを作成しなくてよい。
  • 対象のpackageにアクセスするためのauthtokenなどの運用も考えなくて良い。(チーム開発するならば全員にsetup方法を共有するか、npmrcをソースにコミットするかとあとは本番環境でデプロイする時のtokenのセットなどを考える必要があります。)

ただ、npm workspaceと非常に相性の悪いのがGCPのCloud FunctionsやAWSのLambdaといったものとの連携です。

なぜ相性が悪いのかと言うと、LambdaやCloud Functionsに配置可能なnode_moduelsは一旦zip化されてたりs3やcloud storageといったobject storageを経由するのでその過程でsymbolic linkが消えてしまいます。
当然symbolik linkが使えなければ、参照したいコードがその場所にないと言うことなのでnot foundのExceptionを吐くでしょう。

自分は、一部ファイルをコピーしたりして対応しましたがあまりにもメンテナンス性がわるい(デプロイがシンプルでないのでそのうち無理がたたるだろう)とおもってまた別の道を探す旅に出ました。

※最終的には、LambdaやCloudFunctionsにはロジックを持たせずにイベントをproxyするだけにするというのが一番見通しがいいと言う結論に至りました。

private packgeで運用する

npmのprivate packageやgithubのprivate packageで運用する方法です。
シンプルに解決するにはこれだ!と思っていた時期が私にもありました。

ただ、結論を言うと非常に開発体験が悪い。
開発環境で共通の処理を共有ライブラリに入れるのですが、その度にbuildしてpublishして、npm install または yarn installをしなければいけない。
加えてversionはimmutableにしないといけない。(それはそう)なので、package.jsonのversionプロパティをincrementする必要があります。

ただこの辺はnpmのコマンドで自動でincrementできます。github actionを組んだりもしました。
GitHub Actionで実装する必要があるのは、

  1. build処理
  2. build成果物以外の不要なファイルの削除
  3. npm versionのincrement
  4. npm publish
  5. 修正したpackage.jsonをcommitしてpush

github actionでこの辺をくむのわ割と簡単でしたがそれ以上に、一度publishしてinstallしないと共有ライブラリのコードを参照できないのは不便でした。

version管理ができるので、参照先が複数ある場合にあるソースからはversion0.0.1を参照して、べつの参照先からは0.0.2を参照したいみたいなときには便利です。(本当にそれいる?)

やめました。

workspace機能をうまく使うにはどうしたらいいのか

全体を見直すことにしました。問題の整理です。
1つ目の問題はlambdaやcloud functionsにはsymbolic linkを使えないこと。
2つめの問題はローカルで行ったtsファイルの修正が参照先にいい感じに反映されてほしいこと。

1つ目の問題は、こちらでも記載しましたがそもそもlamdaやcloudfunctionsにビジネスロジックを置きたくないと言うこともあり、workspaceの対象外にしてシンプルにeventをpubsubにプロキシするだけにしてます。
https://zenn.dev/makumattun/articles/120089a0c979ab

2つ目の問題は、workspace機能を使っていたら即時に反映されます。
ただ、webpackeやesbuildのようなbuildツールからすると、共有ライブラリ内に置かれている成果物はesmかcommmonjsのどちらかを期待します。tsファイルをいい感じに検知してbuildはしません。

Next.jsだけから参照されるライブラリであれば、next-transpile-modulesを使用してtsファイルをbuildしてくれます。
next.config.js

const withTM = require('next-transpile-modules')([
  '@twihika/auth',
  '@twihika/share',
  '@twihika/ui',
  '@twihika/hasura',
]);

こんな感じにしてます。

Next.jsとNest.jsのどちらかも参照したいライブラリはどうするのか。
Next.jsはesm形式のファイルを期待していて、Nest.jsはcommonjs形式のファイルを読み込もうとします。
Nest.jsはNode.jsとして実行するので、commonjsなんでしょうね。

なので、その場合はesbuildを用いてesmとcommonjs形式それぞれのフォーマットでbuildします。
build.js

const { build } = require("esbuild");
const { readFile, writeFile } = require("fs/promises");
const { getSchema, printSchema,createPrismaSchemaBuilder } = require("@mrleebo/prisma-ast");
const glob = require('glob')

const main = async () => {
  const { dependencies } = JSON.parse(await readFile("./package.json"));

  const entryFile = "src/index.ts";

  const shared = {
    bundle: true,
    external: Object.keys(dependencies),
    entryPoints,
    logLevel: "info",
    minify: true,
    sourcemap: false,
  };

  build({
    ...shared,
    entryPoints: [entryFile],
    format: "cjs",
    outfile: "./dist/index.cjs",
    target: ["ES2022"],
    platform: "node",
  });

  build({
    ...shared,
    entryPoints: [entryFile],
    outfile: "./dist/index.js",
    format: "esm",
    target: ["ES2022"],
    platform: "node",
  });
};

main();

呼び出されるときにrequireで呼び出されるのか、importで呼び出されるのかでどのファイルを参照してほしいかを定義します。
package.json

  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "require": "./dist/index.cjs",
      "import": "./dist/index.js"
    },
    "./package.json": "./package.json"
  },

あとは、watchコマンドをnodemonかなんかでサクッと追加しておきます。

    "watch": "nodemon --exec \"npm run build\" -e ts --ignore 'src/zod/' --ignore 'dist/'"

開発するときはnpm watchを常に実行することで、共有ライブラリの変更の即時反映が可能です。

デプロイ時の問題

workspaceの機能を使ってmonrepoできたー!って思いますか?いえまだです。デプロイのことまで面倒を見ないとダメです。

Next.jsとNest.jsはDockerfileでデプロイするので、こんてんなーのサイズをできるだけ小さくしなければいけません。依存するライブラリが線形的に増えてコンテナーのサイズが10GBとかなったらスケールが遅くなります。

vercelが開発したturbo

turbo repoの機能を使ってそのプロジェクトで使用しているpackageのみを記載したpackage.jsonを作成することが可能です。

例えば@service/idというNext.jsの場合なのですが、pruneコマンドで/outディレクトリを新しく作成してその配下には依存しているpackageだけが記載されているpackage.jsonが生成されます。

RUN turbo prune --scope=@service/id --docker

https://turborepo.org/docs/reference/command-line-reference#turbo-prune---scopetarget

こうすることによって、monorepoだからと言ってコンテナーサイズが大きくなりすぎないように制御が可能です。

最終的に採用した技術

yarn workspaceとturboです。turboの中でyarnの機能を使っているらしいのでnpmじゃなくてyarnにしてます。あと、yarnv2でpnpmみたいな爆速インストールが可能になっているとかなっていないとか聞いたので、落ち着いたらversion upしてみたい。

やり残したこと

できたら、nextjs-transpile-moduleでやっていることをnestjsとあとはviteとかにもやらせたかった。
watchコマンドで即時反映されるのだけど、vscodeはtsの定義ファイルをキャッシュするのでリロードさせないといけなかったり面倒。あとは、コードジャンプするとソースコードではなくtscコマンドでemitした型情報を見てしまうのでできたらソースコードを見に行ってほしいと言う気持ちがある。

ただ、nestjsとかviteとかのbuildの仕組みにあまり詳しくないので、transpileできるのかどうかもよくわかってない。つらい。

まとめ

typescriptの界隈では、当たり前のことばかりなことばかりだと思いますがやっと時代に追いついた気がします。
monorepoでstorybookも運用して、ちょっとしたcomponent何かはそっちで小さく実装してからプロジェクトに追加したりできるのですごく気持ちいい。

Discussion

ログインするとコメントできます