Vite + TypeScript + gRPC-web でトランスパイルできない問題の対処
バックエンドの API を gRPC にして,フロントエンドでは gRPC-web[1] を用いてプロキシ (Envoy) 経由でバックエンドの API を利用する構成のソフトウェアを開発をしていた際に,フロントエンドのビルドツールとして Vite[2] を利用するとトランスパイルに失敗するという現象に見舞われました.同じ現象で困ってる誰かの役に立てばと思い,現象と自分の対策を記事に残しておきます.「こうすればいいよ」みたいな指摘あればぜひ教えてください.フロントエンド詳しくないので助かります.
起こった現象
再現手順
以下のライブラリの組み合わせでビルド (vite
と vite build
ともに) に失敗するという現象が発生しました.
- Node.js@v16.15.0
- npm@8.5.5
- vite@2.9.9
- typescript@4.5.4
- grpc-web@1.3.1
- google-protobuf@3.20.1
現象を再現させられる最小構成は以下の手順で作成できます.
- Vite のプロジェクトを作成する
最初に Vite のプロジェクトを作成します.
$ npm create vite@latest vite-grpc-sample -- --template vallila-ts
Scaffolding project in /Users/your_home/vite-grpc-sample...
Done. Now run:
cd vite-grpc-sample
npm install
npm run dev
$ cd vite-grpc-sample
$ npm install
今回の現象は素の TypeScript で発生するのでテンプレートは vanilla-ts
にしておきます.
実際にこの問題に当たったときは react-ts
を選んでました.
- gRPC-web と google-protobuf をインストールする
フロントエンドから gRPC で通信するために gRPC-web を使うようにします.google-protobuf は proto ファイルをコンパイルした際に自動生成したファイルが依存しているので一緒にインストールしておきます.
$ npm install grpc-web google-protobuf
- proto ファイルの作成とスタブの作成
gRPC API のインターフェースとなる proto ファイルを作成します.今回は grpc/grpc-web リポジトリに置かれているサンプル[3]を最小限にしたものを利用します.
syntax = "proto3";
message EchoRequest {
string message = 1;
}
message EchoResponse {
string message = 1;
int32 message_count = 2;
}
service EchoService {
rpc Echo(EchoRequest) returns (EchoResponse);
}
そして,grpc-web の README を参考に,proto ファイルからフロントエンドで使用するスタブを生成します.
protocコマンドの説明やらなんやらはここでは省略します.
$ protoc -I=. echo.proto \
--js_out=import_style=commonjs,binary:./src \
--grpc-web_out=import_style=typescript,mode=grpcweb:./src
gRPC-web は --grpc-web_out
には typescript を指定できますが,--js_out
の方にはまだ typescript が指定できません.代わりに --grpc-web_out
に typescript を指定すると --js_out
で生成される *_pb.js
に対応する *_pb.d.ts
が生成されるようです.この機能はまだ experimental と README には書かれているので,今回うまくいかなかったのはここら辺がまだ実験段階だからなのかもしれません.今後に期待?
-
main.ts
で gRPC クライアントをインポートする
プロジェクト作成時に自動生成された main.ts
で, gRPC クライアントを雑に利用してみます.
import * as grpcWeb from 'grpc-web';
import {EchoServiceClient} from "./EchoServiceClientPb";
import {EchoRequest, EchoResponse} from "./echo_pb";
import './style.css'
const app = document.querySelector<HTMLDivElement>('#app')!
/* 追加した部分 */
const client = new EchoServiceClient("http://localhost:3001");
const request = new EchoRequest();
request.setMessage("setMessage しないといけないのめんどくさくない?");
client.echo(request, null, (_: grpcWeb.RpcError, response: EchoResponse) => {
console.log(response.getMessage());
});
/* ここまで */
app.innerHTML = `
<h1>Hello Vite!</h1>
<a href="https://vitejs.dev/guide/features.html" target="_blank">Documentation</a>
`
とりあえず見た目は動きそうです.多分.
現象
準備が揃ったので,上記のコードをビルドして動かしてみます.まずは npm run dev
で開発用サーバを立ててみましょう.
$ npm run dev
vite v2.9.9 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 120ms.
とりあえず動いているように見えますが,http://localhost:3000 にアクセスしてみるとエラーが出ます.
Uncaught SyntaxError: The requested module '/src/echo_pb.js' does not provide an export named 'EchoRequest' (at main.ts:3:1)
echo_pb.js
の EchoRequest
が export されていないと怒られています.
ちなみに npm run build
してみても同じように怒られます.
$ npm run build
> vite-grpc-sample@0.0.0 build
> tsc && vite build
vite v2.9.9 building for production...
transforming (9) src/style.css'EchoRequest' is not exported by 'src/echo_pb.js'
'EchoResponse' is not exported by 'src/echo_pb.js'
'EchoResponse' is not exported by 'src/echo_pb.js'
✓ 9 modules transformed.
'EchoRequest' is not exported by src/echo_pb.js, imported by src/main.ts
file: /Users/your_home/vite-grpc-sample/src/main.ts:3:0
...
対策
なんとなく Vite の設定を変えればうまくいくのかもしれないなーと思いつつ,TypeScriptやらcommonjsやらESMやらフロントエンドは関係性がややこしくてフロントエンド弱者の僕には何もわからん状態になってしまいました.そもそも proto ファイルから出力されたスタブが純粋な TypeScript ならこんな問題は起こらないと思うので純粋なやつ出してくれ頼むって感じでした.
そんなこんなで色々調べてみると以下の Issue を見つけました.
まさに自分と同じ現象で悩まされていますし,どうやら他にも Vite と gRPC-web の関係の悪さに涙を流す人たちはいるようです.そして,この Issue のコメントで protobuf-ts[4] 使うといいよと書かれていました[5].
protobuf-ts とはなんじゃらほいということで調べてみると,Node.jsやブラウザ上で使える gRPC のスタブを TypeScript で生成するライブラリ (プラグイン?なんて呼ぶのが正しいのか教えてください) のようです.
「protobuf-ts で生成したスタブをどうやってブラウザ上で動かすんだ?」と思ってたらどうやら gRPC web transport[6] という仕組みを用いるようです.スタブは protobuf-ts で生成するけど,実際に通信するところは gRPC-web の仕組みを使うぜってことなんですかね.詳しいところはまだわかってないです.
実際にやってみた
ということで以下の手順で protobuf-ts を利用してみます.
-
@protobuf-ts/plugin
と@protobuf-ts/grpcweb-transport
のインストール
$ npm install @protobuf-ts/plugin @protobuf-ts/grpcweb-transport
@protobuf-ts/plugin
は protoc を用いて proto ファイルを TypeScript のスタブにコンパイルするためのライブラリで, @protobuf-ts/grpcweb-transport
は gRPC web transport を使うためのライブラリです.
- スタブの生成
$ npx protoc --ts_out ./src --proto_path . ./echo.proto
上記のコマンドは package.json
のコマンドに追加して実行しやすくしておきましょう.
-
main.ts
で gRPC クライアントをインポートする
以下のように main.ts
を書き換えてあげましょう.
import './style.css'
import {EchoServiceClient} from "./echo.client";
import {GrpcWebFetchTransport} from "@protobuf-ts/grpcweb-transport";
const app = document.querySelector<HTMLDivElement>('#app')!
/* 追加した部分 */
const client = new EchoServiceClient(
new GrpcWebFetchTransport({
baseUrl: 'http://localhost:3001'
})
);
client.echo({message: 'message'}).then((value) => {
const {response} = value;
console.log(response.message);
});
/* ここまで */
app.innerHTML = `
<h1>Hello Vite!</h1>
<a href="https://vitejs.dev/guide/features.html" target="_blank">Documentation</a>
`
- ビルドしてみる
$ npm run build
> vite-grpc-sample@0.0.0 build
> tsc && vite build
vite v2.9.9 building for production...
✓ 52 modules transformed.
dist/assets/favicon.17e50649.svg 1.49 KiB
dist/index.html 0.46 KiB
dist/assets/index.06d14ce2.css 0.17 KiB / gzip: 0.14 KiB
dist/assets/index.3edce131.js 48.59 KiB / gzip: 14.58 KiB
ビルドをしてみても失敗しません.また npm run build
で開発用サーバを立ててアクセスしても以前のようなエラーは発生しません.ただし,proxyもバックエンドサーバも立ててないので当然通信エラーは発生します.このサンプルで通信できるところまでは確認してないですが,実際に開発しているサービスでは疎通確認済みです.多分いけるはず.
おわりに
今回は gRPC-web でスタブを生成せずに, protobuf-ts で生成するという解決策を選びましたが,gRPC-web の --js_out
が TypeScript 対応すればそちらを使えば良さそうです.だけど, protobuf-ts の生成するスタブの方が使いやすそうな印象は受けました.フロントエンド難しいなあ.
Discussion