tRPCでEdge RuntimeとNode.js Runtimeを使い分ける
コールドスタートが重い
Q: 間違って有象無象のライブラリを密結合に導入してバンドルサイズが肥大化したNext.jsをVercelにデプロイするとどうなるでしょうか?
A: ビルドとコールドスタートを待つだけで日が暮れるようになります。
根本解決はバンドルサイズを小さくすることですが、そんなことが一朝一夕にできるなら苦労しません。
ということでEdge Runtimeを使います。
と言いたいところなんですが、tRPCで両ランタイムを共存させるのに苦労したので記事にしておきます。
なお、この記事の内容はStewart Brackenさんのブログをベースに多少の情報を加えています。
原文を直接読みたい方はそちらをどうぞ:
tRPCでEdge Runtimeを使う
やることは大きく3つです。
- Edge Runtime用のcontext/routerを作成する
- Edge Runtime用のAPI Routeを作成する
- 両ランタイムが共存できるclientを作成する
Edge Runtime用のcontext/routerを作成する
まずは既存のNode.js Runtime向けの実装にならってEdge Runtime用のcontextとrouterを作成します。
ここは特に工夫は必要なく、edge-compatibleに書いてあれば問題ありません。
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;
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の一部として含めておきます。
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
を使用します。
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にルーティングするようにします。
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