Node.js on Firebase functionでローカルモジュールを使うために
こんにちはこんにちはTerassの高坂です。
Node.jsのローカルモジュールを使いつつ、Firebase Functionsにいい感じにデプロイする方法を探っていたので、現状採用している方法を紹介します。
問題
GCP Cloud functionで、Node.jsランタイムを使っている場合、依存しているnpmモジュールはGCP上でインストールされます。依存関係の扱い
このときローカルのリポジトリ上でのみ管理しているモジュールを使用していると、npm上に存在しないnode moduleなので見つからずに、デプロイにGCP上でのインストールに失敗します。
ローカルモジュール はクライアント,サーバーの共通モジュールを作ったり、Typescriptでtype共有する際に便利でこれを使ったまま、Firebase Functionsにいい感じにデプロイすることを目指します。
repo/
├─ app/
│ ├─ functions/
│ │ ├─ package.json
│ ├─ frontend/
│ │ ├─ package.json
├─ packages/
│ ├─ common/
│ │ ├─ package.json
イメージとしては、上記のようなディレクトリ構成でfunctions, frontendでcommonをimportしているようなパターンです。
ローカルモジュールとは
ローカルのソース内のみで管理しているnode moduleのことを指します。上記の例だと@terass/common
です。インターナルパッケージやローカルパッケージ色々呼び方があるかと思いますが、ここではローカルモジュールに統一しています。
どうする?
結論からいうとローカルモジュールだけバンドルする形になります。
firebaseのドキュメントを読むと file
files を使ういう選択肢があります. これは package.json
の dependencies
に "file:../../packages/common"
と書いてやる方法ですが、TypeScriptで書いているのでトランスパイルが必要でそのままでは使えません。また、common側でnpmモジュール依存があった場合fucntion側で解決してくれないので、どこかでbundleするなりしないといけません。
そうなるとappビルドより先にcommon側をnpmモジュールをバンドルした上でビルドする必要あります。ビルドプロセスをいじらなきゃいけないのであれば file
を使わなくとも解決できるので、 yarn workspace
とtsup
を使用した方法にたどり着きました。その上で、app以下のnpmモジュール依存はfunction側の依存解決に任せつつ、自身で開発しているローカルモジュールだけバンドルするという仕組みをつくりました。
環境
- Node 18
- TypeScript
- Yarn Workspace
- Firebase Function
- tsup(esbuild)
具体的に
workspace化
まず、app, packagesをyarn workspaceで管理することにします。
これにより、ローカルモジュールの依存関係をpackage.jsonにかけるようになります。
"workspaces": [
"apps/*",
"packages/*"
],
これにより、ローカルモジュールの依存関係をpackage.jsonにかけるようになります。
"dependencies": {
"@terass/common": "*",
},
buildとbundleの設定
ビルドをどうするかというところがキモですが、今回はtsupというesbuildのラッパーを使うことにします。
Bundle your TypeScript library with no config, powered by esbuild.
とのことでesbuildをベースとしたバンドルやポストプロセスの管理ができるようになります。ローカルの開発環境を整えるために採用していますが、頑張ればesbuild単体でも実現可能だと思います。
tsup
の役割ですが、今回はローカルモジュールのバンドル及びトランスパル, ビルドのポスト処理を担当してもらいます。
具体的な手順は以下です。
-
@terass/**
モジュールをバンドルしつつビルドしdist
へ - ビルド後の処理で
@terass/functions
のpackage.jsonから@terass/common
を除き、そのpackage.jsonをdist
へ -
dist
以下をfirebase functionsへデプロイ
という形になります。
これにより、@terass/**
なローカルモジュールをバンドルしつつ、npmモジュールはCloud Functions側の依存関係解決の仕組みに乗っかれます。
以下それぞれの設定の詳細を説明します。
ビルド手順
tsupの設定ファイルは下記になります。
import { defineConfig } from 'tsup'
export default defineConfig((_options) => {
return {
entry: ['src/index.ts'],
outDir: 'dist',
target: 'node18',
splitting: false,
clean: true,
noExternal: [/@terass\/.*/], // Prefixが @terass/ なパッケージだけbundleする
}
})
tsup自体はデフォルトでnode_moduleのバンドルをしません。noExternalにローカルモジュールのpackage名プレフィックスをいれてマッチさせることで@terass/commonのようなパッケージの中身がバンドルされることになります。気をつけなければならないのは、@terass/commonの依存するnpmモジュールもバンドルされることになるので成果物のサイズは増えてしまいます。後述するpostbuildを工夫すればそこも回避できるかもしれませんが、そこまでは対応していません。
tsupでのビルド後に、@terass/**
を除いたpackage.jsonを出力する部分は以下のようなスクリプトを、ビルド後に呼び出します
#!/usr/bin/env node
const INTERNAL_PACKAGES = [/@terass\/.*/]
const OUT_DIR = 'dist'
const fs = require('fs')
const path = require('path')
const packageDeploy = require('./package-deploy.json') // 依存関係の書いてないpackage.json
const package = require('./package.json')
const externalDeps = Object.keys(package.dependencies).filter((dep) => {
return INTERNAL_PACKAGES.every((re) => {
return !re.test(dep)
})
})
if (packageDeploy.dependencies === undefined) {
packageDeploy.dependencies = {}
}
externalDeps.forEach((dep) => {
packageDeploy.dependencies[dep] = package.dependencies[dep]
})
const outFile = path.join(OUT_DIR, 'package.json')
fs.writeFileSync(outFile, JSON.stringify(packageDeploy, null, ' '))
依存関係の書いていない空のpackage-deploy.jsonをコピーして、package.jsonから@terass/**
なモジュールを除き、package-deploy.jsonにマージ。最終的にはdist
以下へpackage.jsonとして書き出します。
ビルドのコマンドは
tsup --onSuccess './postbuild
となります。
まとめ
ローカルモジュールを使いつつ、Firebase Functions(Cloud Functions)にデプロイする方法を紹介しました。yarn workspaceやモノレポが流行る中、1Repo, Multi Moduleな環境は一般的だと思いますが、正直ローカルの依存関係をリモートで解決するのがこんなにめんどくさいとは思いませんでした。Private Registoryを使えという話かもしれませんが、CI/CDや個々の環境にその設定を入れていくのがめんどくさくて、今回はビルドプロセスでカバーする方法をとっています。一度作ってしまえばあとは使い回せる仕組みではあるので、弊社では便利に使っています。
Cloud Functionも第2世代が来てますます便利になりそうなので、Firebase Functions(Cloud Functions)もバンバン使って行きましょう。
Discussion