🍣

tRPCでEdge RuntimeとNode.js Runtimeを使い分ける

に公開

コールドスタートが重い

Q: 間違って有象無象のライブラリを密結合に導入してバンドルサイズが肥大化したNext.jsをVercelにデプロイするとどうなるでしょうか?

A: ビルドとコールドスタートを待つだけで日が暮れるようになります。

根本解決はバンドルサイズを小さくすることですが、そんなことが一朝一夕にできるなら苦労しません。

ということでEdge Runtimeを使います。

と言いたいところなんですが、tRPCで両ランタイムを共存させるのに苦労したので記事にしておきます。


なお、この記事の内容はStewart Brackenさんのブログをベースに多少の情報を加えています。

原文を直接読みたい方はそちらをどうぞ:

https://stewartcodes.hashnode.dev/how-to-incrementally-adopt-edge-functions-with-multiple-trpc-backends

tRPCでEdge Runtimeを使う

やることは大きく3つです。

  1. Edge Runtime用のcontext/routerを作成する
  2. Edge Runtime用のAPI Routeを作成する
  3. 両ランタイムが共存できるclientを作成する

Edge Runtime用のcontext/routerを作成する

まずは既存のNode.js Runtime向けの実装にならってEdge Runtime用のcontextとrouterを作成します。

ここは特に工夫は必要なく、edge-compatibleに書いてあれば問題ありません。

server/edge/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';

export const createEdgeContext = async () => ({
    // edge-compatible initialization
});

const t = initTRPC.context<unknown>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

export const createEdgeRouter = t.router;
export const publicEdgeProcedure = t.procedure;
server/edge/root.ts
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { createEdgeRouter } from './trpc';

export const edgeRouter = createEdgeRouter({
  // your routers
});

export type EdgeRouter = typeof edgeRouter;
export type EdgeRouterInputs = inferRouterInputs<EdgeRouter>;
export type EdgeRouterOutputs = inferRouterOutputs<EdgeRouter>;

このとき、edgeRouterをNode.js Runtime用のrouterの一部として含めておきます

server/api/root.ts
export const appRouter = createTRPCRouter({

  // Node.js Runtime routers
  // ...

  edge: edgeRouter, // Add your Edge Runtime router here
});

これにより、最後にクライアントを作成する際に1つのルーターで両ランタイムの型情報を利用できるようになります。

Edge Runtime用のAPI Routeを作成する

次にEdge Runtime用のAPI Routeを作成します。

この際、fetchRequestHandlerを使用します。

app/api/edge/[trpc]/route.ts
import { NextRequest } from 'next/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createEdgeContext } from '~/server/edge/trpc';
import { edgeRouter } from '~/server/edge/root';

async function handler(req: NextRequest) {
  return fetchRequestHandler({
    req,
    endpoint: '/api/edge',
    router: edgeRouter,
    createContext: createEdgeContext,
  });
}

export const runtime = 'edge';
export { handler as GET, handler as POST };

両ランタイムが共存できるclientを作成する

ここが一番のポイントです。

tRPCのクライアントは、当然ながらサーバーのランタイムに依存しないため、Edge RuntimeとNode.js Runtimeの両方をサポートするクライアントを作成できるはずです。

しかし、基本的にcreateTRPCNextでは1つのrouterの情報のみを受け取るため、工夫しないと両ランタイムを共存させることができません。

以下のようにカスタムリンクを用いて、edgeのプレフィックスが付いている場合だけパスをリライトしてEdge Runtime用のAPI Routeにルーティングするようにします。

utils/api.ts
export const api = createTRPCNext<AppRouter>({
  config() {
    return {
      transformer: superjson,
      links: [
        (runtime) => {
          const servers = {
            defaultServer: httpBatchLink({ url: `${getBaseUrl()}/api/trpc` })(runtime),
            edge: httpBatchLink({ url: `${getBaseUrl()}/api/edge` })(runtime),
          };

          return (ctx) => {
            const { op } = ctx;
            const pathParts = op.path.split('.');
            const serverName = pathParts[0];

            // Route to edge server if path starts with 'edge.'
            if (serverName === 'edge') {
              pathParts.shift(); // Remove 'edge' prefix
              const path = pathParts.join('.');
              return servers.edge({ ...ctx, op: { ...op, path } });
            }

            return servers.defaultServer(ctx);
          };
        },
      ],
    };
  },
  ssr: false,
});

linksが受け取る型

linksが少々複雑なので、型を確認しておきます。

まず、linksは以下の型を受け取ります。

TRPCLink < TRouter > [];

ここで、TRPCLinkの定義は以下です。

export type TRPCLink<TRouter extends AnyRouter> = (
  opts: TRPCClientRuntime,
) => OperationLink<TRouter>;

引数となるoptsは、httpBatchLinkにそのまま渡される用途のため、深追いしません。

問題は、再び関数として返るOperationLink<TRouter>の定義です。

export type OperationLink<TRouter extends AnyRouter, TInput = unknown, TOutput = unknown> = (opts: {
  op: Operation<TInput>;
  next: (op: Operation<TInput>) => OperationResultObservable<TRouter, TOutput>;
}) => OperationResultObservable<TRouter, TOutput>;

なるほどconst { op } = ctx;で取り出していたopの型定義にたどり着きました。

このopにパスが含まれるので、リライトして元のhttpBatchLinkに渡せばよいということがわかります。

export type Operation<TInput = unknown> = {
  id: number;
  type: 'mutation' | 'query' | 'subscription';
  input: TInput;
  path: string;
  context: OperationContext;
};

Edge Runtimeのエンドポイント呼び出し

Edge Runtimeのエンドポイントを呼び出す際は、api.edgeを利用します。

const HelloComponent = () => {
  const [hello] = api.edge.hello.useSuspenseQuery();

  return <div>{hello}</div>;
};

おわりに

以上のように、カスタムリンクを定義してあげることで、tRPCでEdge RuntimeとNode.js Runtimeを共存させることができます。

私は冒頭で紹介したブログを読むまで、組み込みのsplitLinkを使ってなんとかしようとしていたのですが、splitLinkだと今回定義したものと違ってパスのリライトには対応していません。
そのため、今回のようにedgeRouterをネストした1つのrouterを基準に作ろうとしても、API Routeに到達したあとのパス不一致でうまく動かせませんでした。

一応、tRPCの公式ドキュメントにはlinksのセクションが存在していて、カスタムリンクの構築については触れられていますが、最初に見た当時の私はちゃんと型を追いきらず自作を諦めた記憶があります。

実際にはとてもシンプルな手法でクリアできるということが分かりましたので、次に似たような場面があれば、もうちょっと粘って知恵を絞ろうかなと思います。

キリフダ株式会社

Discussion