Next.js API Route の実装時に同じファイルでクライアントコードも実装すると嬉しい…
tl;dr
- API Route でクライアント用のコードも一緒に実装すると型安全に実装しやすい
- ただしセキュリティは気をつけてね
発想
Next.js から Prisma ORM を利用する という記事で、 Next.js の getServerSideProps
で prisma のコードを使う例を紹介しました。
これは pages というユニバーサルなエンドポイントで、クライアントとサーバー用のコードを両方記述しています。
import type { GetServerSideProps } from "next";
import prisma from "../lib/prisma";
type Props = {
count: number;
};
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
const count = await prisma.user.count();
return {
props: {
count,
},
};
};
export default function Index(props: Props) {
return <div>user count: {props.count}</div>;
}
じゃあ逆に、 Next.js の API Route で、サーバーだけではなくクライアントのコードを実装してしまってもいいんじゃないか?と思ってみたら、案外使い勝手がよかったので、紹介します。
API Route にそれを呼ぶクライアントコードを記述する例。
Deno から npm パッケージを使用するノウハウを読んで、 x-typescript-types を取得したくて作った API Route です。
/* How to use
import { getTypes } from "../api/types"
const result = await getTypes("https://esm.sh/react");
if (result.hasTypes) {
console.log(result.typesCode);
}
*/
import { NextApiHandler } from "next";
import fetch from "isomorphic-unfetch";
import * as zod from "zod";
const requestSchema = zod.object({
b64: zod.string().min(5),
});
type GetTypesResponse =
| {
hasTypes: true;
typesUrl: string;
typesCode: string;
}
| {
hasTypes: false;
};
const handler: NextApiHandler<GetTypesResponse> = async (req, res) => {
try {
const r = requestSchema.parse(req.query);
const codeUrl = Buffer.from(r.b64, "base64").toString("utf-8");
const response = await fetch(codeUrl);
const typesUrl = response.headers.get("x-typescript-types");
if (typesUrl == null) {
return res.json({ hasTypes: false });
}
const typesCode = await fetch(typesUrl).then((res) => {
return res.text();
});
res.json({ typesUrl, typesCode, hasTypes: true });
} catch (err) {
res.status(400).end();
}
};
export default handler;
export async function getTypes(url: string): Promise<GetTypesResponse> {
const encoded = btoa(url);
return fetch(`/api/types/${encoded}`).then((res) => res.json());
}
この getTypes
がミソです。他のコードからは、 import { getTypes } from "./api/types"
みたいな形で呼べます。呼び出し時の URL 直指定ではなく、API 実装、クライアント実装、型アノテーション、バリデータを同じファイルに記述しているので、型を共有するコードが書きやすいです。
エンドポイントを示す pages/api
から import してるので、コードを書く側のマインドセットとしても自然な気がします。
colinhacks/zod: TypeScript-first schema validation with static type inference は TS フレンドリーなバリデータです。スキーマを組み立てて、バリデーションが通ると、その型であることが保証されます。今回は GET の query ですが、普通は req.body に対して使うような気がします。
セキュリティ
この手法を正しく使うにあたって、 isomorphic という概念と、Tree Shaking の二つを理解しておく必要があります。
Next.js 前提として、このファイルはクライアント、サーバー別々にビルドされます。 isomorphic-unfetch はクライアント・サーバーどちらからでも import できる使えるライブラリなので、区別なく使えます。
base64 に encode する btoa
はクライアントにないブラウザの API で、サーバーでは実行できません。逆に、この handler のコードはクライアントで実行しても、 Access-Control-Allow-Headers
がないので、x-typescript-header
が取得できません。今回はクライアント・サーバーの権限の差異ですが、普通はサーバーサイドでしか呼べないコードを含めると思うので、単にビルドエラーになるはずです。
このファイルは、 API Route によって export default
が、 クライアントコードからの import によって export function getTypes
が、それぞれ Tree Shaking ビルドされます。このとき、参照しないコードは terser の最適化ステップで Dead Code Elimination されて消えます。
ということを知った上で、絶対に書いてはいけない疑似コードを紹介しておきます。
const config = {
url: "https://...",
secretToken: "...",
};
export default async (req, res) => {
await callInternalApiWithAuth(req.body, config.secretToken);
res.send("ok");
};
export function request() {
await fetch(config.url);
}
secretToken
はサーバーサイドにのみ知って良いコードだとして、config は クライアントからも参照しているので、 terser の Dead Code Elimination でも削除されず、クライアントに露出します。これによって悪意ある第三者が secretToken を知ることができます。
これは API Route のみならず、 pages/*
の getServerSideProps
でも同じです。気をつけてください。
心配な人は、機械的に検知するために、ビルド済みのコードに対して secretlint
を掛けたりするといいかもしれません。
secretlint/secretlint: Pluggable linting tool to prevent committing credential.
(といいつつ自分はまだちゃんとやってないので、あとで調べる)
発展的な解決案: Endpoint を自動で取得する
↑ のコードのうち getTypes
の実装の /api/types/...
の部分が、自分で辻褄を合わせないといけない部分として残ってしまっています。
これを解決するために、 webpack の設定を書き換えて、 クライアントサイドで __filename
を参照できるようにしてみます。
next.config.js
const path = require("path");
module.exports = {
target: "serverless",
webpack(config) {
config.node = {
...config.node,
__filename: true,
__dirname: true,
};
return config;
},
};
これで __filename
が参照できるので、これを使ってエンドポイントを自動推定できる…はずです。
export async function getTypes(url: string): Promise<GetTypesResponse> {
console.log(__filename); // pages/api/types/[b64].ts
const encoded = btoa(url);
return fetch(`/api/types/${encoded}`).then((res) => res.json());
}
動的パラメータを含まないリクエストなら、これを使うのは簡単なはずです。今回は面倒くさくなったんですが、これをいい感じに正規表現でパースするなりすると、安全に URL を組み立てられるはずです。誰かやっといてください。
pillarjs/path-to-regexp: Turn a path string such as /user/:name
into a regular expression あたりを参考にすると作れそう。Next.js 内のユーティリティとしてどこかにあるはず…。
命名の儀
似たようなことをやってる人がいないので、勝手に mizchi スタイルと命名します。やってみてね。
Discussion