ゼスト Tech Blog

ゼストは「護りたい。その想いを護る。」をミッションに、在宅医療・介護業界向けのSaaSを開発しています。

tRPCでもファイルアップロードしたい

こんにちは。株式会社ゼストでバックエンドを担当している正原です。
最近は懲りずにまたフロントエンドに手を出してしまい、自分の古い知識に絶望していますが、強く生きていこうと思います。

さて、すでに本ブログで紹介されているように、弊社ではtRPCを用いてWEBアプリケーションを開発しています。

techblog.zest.jp

開発に利用し始めてから約半年ほどになりますが、型チェックによってうっかりフロントエンドでエラーが出てしまうような変更を何度も未然に防げていて、非常に助かっています。
またフロントエンドとサーバーサイドをTypeScriptという言語に統一しつつモノレポで開発してるので、そのような破壊的変更を行う場合においても、フロントエンドのことを詳しく分かっていないサーバーサイドエンジニアでも簡単な修正が行えるのも大きな利点です。

そんなゼストでのtRPCを用いたWEBアプリケーション開発ですが、今回はファイルアップロードで困ったこと・それをどうやって解決したのかを紹介したいと思います。

tRPCでのファイルアップロード

要件としてファイルアップロードが必要と聞いたとき、ぱっと思いついたのは以下の手法でした。

  • BASE64エンコードしてリクエストに含める
  • tRPCではなくREST APIなどの別のエンドポイントを用意する
  • 署名付きURLで直接クラウドのストレージへアップロードする
  • tRPCで multipart/form-data 形式のファイルアップロードを頑張る

まずBASE64エンコードしてリクエストに含めるのは、大きな画像や動画などのバイナリファイルを想定すると少々不安なので見送りました。
次に、別のエンドポイントを用意する方法についてですが、tRPCで実装してきたミドルウェアによる認証機構などをそのままでは再利用できないので、なるべくなら避けたいと思いました。
署名付きURLについては、もしかしたら今後開発を進めていく中で変更するかもしれませんが、今回は検証として tRPC のエンドポイントで multipart/form-data 形式でのファイルアップロードが可能かどうかを試してみました。

サンプルを試す

tRPCでの multipart/form-data 形式でのファイルアップロードについてですが、現時点(2024/01)ではまだ experimental ステータスではありますが、公式によってサポートされておりサンプルコードもあります。

github.com

GitHubに記載されている通りですが、以下のコマンドで実際に動かすことができます。

$ npx create-next-app --example https://github.com/trpc/trpc --example-path examples/.experimental/next-formdata trpc-formdata
$ cd trpc-formdata
$ npm i
$ npm run dev

これでWEBアプリケーションが立ち上がるので、ブラウザからアクセスすることによって、以下のようにそれぞれのサンプルフォームからファイルをアップロードすることができます。

同様にcurlを用いてファイルアップロードが可能なことも確認できます。

$ curl -s -XPOST -F 'name=test' -F 'image=@./zest-techblog.png' 'http://localhost:3000/api/trpc/room.sendMessage'|jq .
{
  "result": {
    "data": {
      "image": {
        "url": "/uploads/1702573308588/zest-techblog.png",
        "name": "zest-techblog.png"
      }
    }
  }
}

プロダクトに導入

サンプルが動くのを確認できたので、大勝利を確信して試しに現在開発しているプロダクトに導入してみました。

$ curl -s -XPOST -F 'file=@./zest-techblog.png' -F 'message=test' 'http://localhost:3000/trpc/upload'
... Cannot destructure property 'json' of 'payload' as it is null. ...

結果は予想とは大きく異なり、無惨にも大敗北となりました。

原因調査

残念ながらサンプルのように簡単に動かすことはできませんでしたが、サンプルとプロダクトの差分を考慮して、サンプルを徐々にプロダクトと同じ状態に近づけていき、何が異なるかを調査していきました。

superjson

最初に疑ったのは superjson です。エラーの内容が json というプロパティがない(null)ということだったので、superjson のデシリアライズで失敗しているのではないか?と思い、サンプル実装にトランスフォーマーとして superjson を用いるよう実装を変更してみました。

import { initTRPC } from '@trpc/server';
import { Context } from './context';
import superjson from 'superjson';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
});

結果は問題なく動いたので、この検証から superjson が直接的な原因ではないということが分かりました。
(疑ってごめんなさい😭)

fastify

次は何も候補が思いつかなかったのですが、他に差分らしい差分が fastify しかなかったので、node ではなく fastify を用いるようにプラグインを導入しました。
fastify プラグインの利用方法は公式ドキュメントに記載されているので、それに従って実装していきます。

trpc.io

export type AppRouter = typeof appRouter;

const server = fastify({
  maxParamLength: 5000,
});

server.register(multipart);

server.register(fastifyTRPCPlugin, {
  trpcOptions: {
    router: appRouter,
    createContext,
    experimental_contentTypeHandlers: [
      nodeHTTPFormDataContentTypeHandler(),
      nodeHTTPJSONContentTypeHandler(),
    ],
  } // satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
});

怪しいコメントアウトをしていますが、まさにここが原因で動かなかったようです。
実際、ソースコードを追っていくと fastify プラグインの方には experimental_contentTypeHandlers というハンドラを利用するような実装はありませんでした。
このハンドラがないことは satifies さえ記述していれば気付けたのに・・・と悔しく思いましたが、公式ドキュメントにある satisfies をわざわざ消すメリットはないので新しく追記されたのかもしれないと思ったところ、tRPC レポジトリにあるドキュメントの該当行の最終更新履歴が2024/01/03となっていたので、もしかしたら私と同じような勘違い予防のために、公式ドキュメントに最近追記されたのかもしれません。

ワークアラウンド

では fastify プラグインが原因であることは分かりましたが、まだ試験運用中の機能のために fastify から node に変更するかどうかは悩みどころだと思います。
また、今回の検証で出たエラーが「json というプロパティがnull」という内容だったので、もしかしたらトランスフォームのタイミングで一工夫したら回避できないかと思って試行錯誤してみました。

const transformer: DataTransformerOptions = {
  serialize: superjson.serialize,
  deserialize: (data: never) => {
    const payload: SuperJSONResult = data ?? { json: null };
    return superjson.deserialize(payload);
  },
};

const t = initTRPC.context<Context>().create({ transformer });

結果として、こちらの修正を加えることで他の実装に大きな影響を与えず fastify プラグインおよび superjsonトランスフォーマーとして利用しても、 multipart/form-data でのファイルアップロードが可能であることが分かりました。

$ curl -s -XPOST -F 'file=@./assets/1.png' -F 'message=test' localhost:3000/upload|jq .
{
  "result": {
    "data": {
      "json": {
        "message": "OK"
      }
    }
  }
}

正常なリクエストのペイロードとして dataundefined であるリクエストが全て { json: null } になってしまうのが少し気になるところです。
ですが、そのようなリクエストは本来であればエラーになっていましたし、実際のエンドポイントでは tRPC の入力の型をスキーマ等によって定義するので、パースのタイミングで同様に失敗すると思われるため、あまり大きな問題にはならないかもしれません。

最後に

今回はファイルアップロードの検証を通して、tRPCのサーバー導入部分をより深く理解できてよかったたと思います。
せっかくいくつか検証してみたので、今回記事として取り上げてみました。
今回検証に用いた実装内容はサンプル実装として GitHub にアップロードしてありますので、よろしければ参考にしていただければと思います。

github.com