目次
はじめに
背景
IP アドレス制限
Apollo Client の構成
問題: Cannot find module "fs"
試したこと
1. ランタイムガードで分岐する
2. dynamic import で遅延ロードする
3. .env に URI を書く
4. SSR / CSR の Apollo Client を完全にファイル分割する
解決策: webpack の NormalModuleReplacementPlugin
.server.ts / .client.ts パターン
仕組み
ファイル構成
なぜこの方法がうまく動くのか
型の扱いに関する注意点
本来あるべき姿と今回の位置づけ
短期的な対応: webpack による出し分け
長期的な方向性
おわりに
はじめに
こんにちは、Wantedly の採用管理サービス「Wantedly Hire」の開発をしている Toranosuke Ujike(@tora_tora_bit) です。今回は IP アドレス制限機能の開発中に遭遇した「SSR 用の Node.js 専用モジュールが CSR 用のバンドルに混入してしまう」という問題と、その解決策について紹介します。
具体的には、 Apollo Client で URL 指定のために Node.js 専用の社内ライブラリを利用する必要があり、ブラウザで Module not found: Can't resolve 'fs' というエラーが発生しました。実行時にサーバーかクライアントかを判定する分岐やガードや dynamic import など複数のアプローチを試した結果、最終的に webpack の NormalModuleReplacementPlugin を使ってビルド時にモジュールを切り替える方法にたどり着きました。
この記事は、同じ問題で困っている方に向けて、問題の構造から解決策までを共有するものです。
背景
IP アドレス制限
Wantedly Hire に IP アドレス制限機能を追加するプロジェクトに取り組みました。これは、指定した IP アドレスレンジからのアクセスだけを許可するための機能です。この機能を正しく動作させるためには、リクエスト元のクライアント IP アドレスをバックエンドまで正確に伝搬させる必要がありました。
Wantedly Hire は AWS 上に構築されています。外部からのリクエストは、AWS の Application Load Balancer(ALB)を経由して各アプリケーションへルーティングされます。
全体のリクエスト経路は以下のようになっています。
IP アドレス制限では、最終的にバックエンドがアクセス可否を判断します。そのため、バックエンドには「最初にリクエストを送った Client の IP アドレス」が正確に伝わっている必要があります。
CSR の場合、ブラウザからのリクエストは直接 ALB に到達します。ALB は X-Forwarded-For ヘッダーに Client の IP アドレスを付与し、そのまま GraphQLサーバー、バックエンドサーバー へと伝えます。この経路では、Client の IP アドレスは自然に保持されるため、特別な対応は不要でした。
一方、SSR の場合は事情が異なります。クライアント(ブラウザ)からのリクエストは一度Next.js サーバーに到達し、その後 Next.js サーバーが改めて GraphQL サーバーにリクエストを送ります。このとき、GraphQL サーバーから見るとリクエスト元はフロントエンドのサーバー(Next.js サーバー)になります。つまり、本来バックエンドまで伝わるべき Client の IP アドレスが途中で失われてしまっていたのです。
この問題を解決するために、SSR 用のリクエスト経路を利用しました。SSR では フロントエンドのサーバープロセスが Kubernetes クラスタ内部から直接 GraphQL サーバーにリクエストを送ります。ALB が付与する X-Forwardef-For ヘッダーには、最初の Client の IP アドレスが含まれています。これを「アクセス元の Client IP」として明示的に引き継ぐことで、IP 制限の判断を行なうバックエンドまで正確な IP アドレスを伝搬させられるようになりました。
ここまでをまとめると、正しくクライアントの IP アドレスをバックエンドまで伝搬させるためには、SSR と CSR で別々の経路を辿らせる必要がありました。CSR では公開エンドポイントを、SSR ではクラスタ内部のエンドポイントを使います。
Apollo Client の構成
Wantedly Hire のフロントエンドでは Apollo Client を使って GraphQL リクエストを行っています。Apollo Client は Link チェーンというものを作ってリクエストを処理する仕組みになっており、各 Link はリクエストの加工・監視・再試行といった役割を担います。
SSR 固有の最適化やセキュリティ上の理由で CSR と SSR でそれぞれ別の Apollo Client インスタンスを使い分けていますが、認証やエラーハンドリングなどの共通ロジックは同じファイル内で定義されています。
// CSR 用: 公開エンドポイントを使用
export const client = new ApolloClient({
link: from([errorLink, authMiddleware, contextPropagationMiddleware, createHttpLink(getUriForCSR())]),
cache: new InMemoryCache({ ... }),
});
// SSR 用: Kubernetes クラスタ内部のエンドポイントを使用
export const clientWithSSRContext = (ssrContext) => {
return new ApolloClient({
link: from([
ssrContextLink,
errorLink,
authMiddleware,
contextPropagationMiddleware,
createHttpLink(getUriForSSRInternal()), // ← Node.js 専用ライブラリで内部 URI を取得
]),
});
};
SSR 用の Apollo Client インスタンスでは、Kubernetes クラスタ内部の URI を取得するために servicex という 社内ライブラリを使います。servicex とはマイクロサービスの開発基盤として社内で開発しているライブラリです。servicex は各言語向けの実装が用意されており、ここでは Node.js 用のものを使います。servicex にはすべてのマイクロサービスの k8s 内の URI を取得できる仕組みが整備されているため、これを使用する予定でした。
当初の実装イメージは以下のとおりです。servicex から内部 URI を取得し、HttpLink に渡すことで SSR 時にクラスタ内通信を実現します。
// 当初の実装イメージ
import { AtsGraphqlGatewayApi } from "@wantedly_private/servicex";
export const getUriForSSRInternal = () => {
return `${AtsGraphqlGatewayApi.internal}/internal/graphql`;
};// apolloClient.ts での利用
import { getUriForSSRInternal } from "./getUriForSSRInternal";
export const clientWithSSRContext = (ssrContext) => {
return new ApolloClient({
cache: new InMemoryCache({ /* ... */ }),
link: from([
ssrContextLink,
errorLink,
authMiddleware,
contextPropagationMiddleware,
createHttpLink(getUriForSSRInternal()), // servicex から取得した内部 URI で HttpLink を作成
]),
});
};
CSR 用のクライアントは React コンポーネントから useQuery や useMutation 経由で利用されます。ApolloProvider にシングルトンの client を渡すことで、配下のすべてのコンポーネントから GraphQL リクエストを実行できます。
// pages/_app.tsx
import { ApolloProvider } from "@apollo/client";
import { client } from "@/lib/apollo/apolloClient";
const App = ({ Component, pageProps }: AppProps) => {
return (
<ApolloProvider client={client}>
<Component {...pageProps} />
</ApolloProvider>
);
};
// features/some-feature/SomeComponent.tsx
import { useQuery } from "@apollo/client";
const SomeComponent = () => {
const { data, loading } = useQuery(SOME_QUERY);
// ...
};
一方、SSR 用のクライアントは getServerSideProps の中で以下のように使われます。
// pages/app/some-page.tsx
export const getServerSideProps: GetServerSideProps = async (context) => {
const { data } = await clientWithSSRContext(context).query({
query: SOME_QUERY,
variables: { /* ... */ },
fetchPolicy: "no-cache",
});
return { props: { data } };
};
ここで問題が起きました。
問題: Cannot find module "fs"
上のように Apollo Client を分割した状態で、アプリケーションのページを開いたところ以下のエラーが表示されてしまいました。
Module not found: Can't resolve 'fs'なぜこれが起きてしまったかというと、servicex が内部で fs を使っているためでした。しかしながら、インスタンスを SSR と CSR で分けているのに、このエラーがブラウザで出ているのかわかりませんでした。
原因は、apolloClient.ts という単一ファイルの中に CSR 用と SSR 用の両方のインスタンスが宣言されていたことにあります。webpack は CSR バンドルをビルドする際、このファイル全体を解析対象とします。たとえ SSR 用のインスタンスが CSR から直接呼ばれなくても、同一ファイル内にある servicex の import は CSR バンドルに取り込まれてしまいます。では、なぜ CSR 用と SSR 用のクライアントを同一ファイルに置いていたのでしょうか。このプロジェクトでは認証トークンが切れた場合に自動で再取得をしてリクエストを再試行する ErrorLink が実装されています。この ErrorLink をはじめ、認証やエラーハンドリングなど多くのロジックが CSR/SSR で共有されており、これらを 1 つのファイルにまとめて管理するのが自然な構成でした。しかし、SSR 用のインスタンスが servicex に依存していたことで、この構成が裏目に出てしまったのです。
試したこと
1. ランタイムガードで分岐する
最初に試したのは、実行環境の判定で servicex の読み込みを回避するアプローチでした。
if (typeof window === "undefined") {
const { AtsGraphqlGatewayApi } = require("@wantedly_private/servicex");
// ...
}しかし、これではうまくいきません。webpack はビルド時に静的解析ですべての import / require を検出し、バンドルに含めるモジュールを決定します。if 文はランタイムでしか評価されないため、条件に関係なくブラウザ用バンドルにも servicex が含まれてしまいます。
2. dynamic import で遅延ロードする
const getUriForSSRInternal = async () => {
const { AtsGraphqlGatewayApi } = await import("@wantedly_private/servicex");
return `${AtsGraphqlGatewayApi.internal}/internal/graphql`;
};dynamic import 自体は CSR バンドルから除外できる可能性がありますが、top-level await が必要になるなど、やりたいことに対して複雑になりすぎました。Apollo Client の Link チェーンは同期的にモジュールを組み立てる設計であり、非同期処理との相性が悪く、コード全体の見通しが大きく悪化します。
3. .env に URI を書く
環境変数から読む方式であれば servicex への依存を消せるため、一時的な回避策としてはこの方法を採用しました。ただし、ウォンテッドリーでは servicex にサービス間通信の設定を集約する設計になっているため、.env に個別のサービス URI を書くのは基盤の思想から外れてしまいます。
4. SSR / CSR の Apollo Client を完全にファイル分割する
SSR 用と CSR 用で apolloClient.ts 自体を分けるアプローチも検討しました。しかし、ErrorLink やトークンリフレッシュなど多くのロジックが SSR/CSR で共有されており、これを完全に分離すると変更差分が大きくなりすぎます。動作確認の範囲も広がるため、やりたいことに対して割に合いませんでした。
解決策: webpack の NormalModuleReplacementPlugin
.server.ts / .client.ts パターン
調査を進めていく中で、コードベースに *.server-or-client.ts というファイル命名パターンが存在することに気づきました。社内の先輩エンジニアが共通の課題を持っており、webpack の設定でビルド時にモジュールの解決先を切り替える方法を導入していたのです。
問題を共有してみたところ、自分が直面しているのが同じ種類の問題であることが確認できました。この仕組みは、同じ import 文から読み込まれるモジュールの実体を SSR ビルドと CSR ビルドで切り替えることができます。つまり、SSR 用のバンドルには service を含め、CSR 用のバンドルには含めないという制御がビルド時に実現できます。
仕組み
webpack の NormalModuleReplacementPlugin を使い、.server-or-client というサフィックスを持つファイルを、ビルドターゲットに応じて .server または .client に置換します。
// next.config.mjs
webpack(config, { isServer, webpack }) {
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(
/\.server-or-client(?=\.|$)/,
(resource) => {
resource.request = resource.request.replace(
/\.server-or-client(?=\.|$)/,
isServer ? ".server" : ".client"
);
}
)
);
},
Next.js は SSR 用と CSR 用で別々の webpack ビルドを実行します。isServer は Next.js の webpack 設定コールバックに渡されるフラグで、そのビルドがサーバー向けかクライアント向けかを示します。このプラグインによって、モジュール解決がビルド時に静的に分岐されます。
ファイル構成
1つの機能につき 3 つのファイルを用意します。
src/lib/apollo/
├── getUriForSSRInternal.server-or-client.ts # 型推論用のプレースホルダー
├── getUriForSSRInternal.server.ts # SSR 用の実装
└── getUriForSSRInternal.client.ts # CSR 用の実装■ 型推論用(.server-or-client.ts)
各ファイルから参照するために利用されるファイルです。webpack はビルド時にこのファイルを .server.ts か .client.ts に置き換えるため、実行されることはありません。
export const getUriForSSRInternal = () => {
throw new Error("This function is just for type inference");
};■ SSR 用 (.server.ts)
Node.js 環境で実行されることを前提としたバンドルに含まれます。ここでは Node.js ライブラリを利用できるので servicex から URL を取得する処理を書きます。
import { AtsGraphqlGatewayApi } from "@wantedly_private/servicex";
const getBaseUriForSSR = (): string => {
return AtsGraphqlGatewayApi.internal as string;
};
const buildInternalUri = (internalUri: string) => `${internalUri}/internal/graphql`;
export const getUriForSSRInternal = () => buildInternalUri(getBaseUriForSSR());■ CSR 用 (.client.ts)
Web ブラウザで実行されることを前提としたバンドルに含まれます。今回の場合、ブラウザから呼ばれないはずの関数なので、呼び出された場合にすぐ気づけるようにガードを入れておきます。
export const getUriForSSRInternal = () => {
throw new Error("getUriForSSRInternal is not implemented for client");
};利用側では .server-or-client を import するだけで、環境に応じた実装が自動的に読み込まれます。
// getUrl.ts
export { getUriForSSRInternal } from "./getUriForSSRInternal.server-or-client";なぜこの方法がうまく動くのか
ポイントはビルド時の静的な分離です。
- webpack がモジュールを解決する段階で .server-or-client が .server または .client に置き換わる
- SSR バンドルには
.server.tsが含まれ、servicex の import が解決される - CSR バンドルには
.client.tsが含まれ、 servicex の import は存在しない - ブラウザが fs を解決しようとしてエラーになることがない
ランタイムガードとの決定的な違いは、 import 文がバンドルに含まれるかどうかがビルド時に確定するという点です。ランタイムでいくら分岐しても、webpack がすでにバンドルに含まれてしまった後ではエラーになってしまいます。
型の扱いに関する注意点
TypeScript の型チェック時には webpack は介入しないため、.server-or-client.ts が型推論の基準になります。ここで重要なのは、3つのファイルすべてで同じ関数名・同じシグネチャをエクスポートすることです。
モジュール内部で完結する関数や変数は各ファイルで自由に定義して問題ありません。しかし、エクスポートの型が .server.ts と .client.ts で不一致になっていても、TypeScript は .server-or-client.ts しか見ていないためコンパイル時に検出されず、ランタイムで初めてエラーになります。この点は注意が必要です。
本来あるべき姿と今回の位置づけ
今回の問題の根本は servicex がサーバーサイド専用に作られたライブラリであり、内部で fs などの Node.js 依存を持っているという点にあります。そのため、 SSR と CSR の両方で読み込まれるコード(以降「 isomorphic なコード」と呼びます)で servicex を import すると必然的に失敗してしまいます。
短期的な対応: webpack による出し分け
今回採用した NormalModuleReplacementPlugin による出し分けは、すでにコードベースに存在する webpack 設定を活用した短期的な対応です。既存コードへの変更が最小限で済む実用的な方法ではありますが、webpack の設定をいじって無理矢理モジュールを差し替えているという点で、本来的なやり方ではありません。
長期的な方向性
Next.js を使い続けると、SSR/CSR 両方で実行されるケースがあり得るので、isomorphic を意識することには価値があると考えています。この問題に対しては、使う側・ライブラリ側の双方からアプローチできます。
■ ライブラリ側の対応
長期的には、servicex のようなライブラリの立ち位置をあらためて整理し、isomorphic な形で利用できるようにしていくことが重要です。
具体的には、package.json の exports フィールドを使い、ブラウザ向けとサーバー向けのエントリポイントを分けるのが最も移植性の高い方法です。
{
"exports": {
".": {
"browser": "./dist/browser/index.js",
"default": "./dist/node/index.js"
}
}
}webpack や Node.js のモジュール解決がこの exports を参照し、実行環境に応じて適切なファイルを読み込みます。ライブラリ側でこの対応がされていれば、使う側で webpack の設定を追加したり、.server.ts / .client.ts のファイル分割を行う必要はなくなります。
■ 使う側の対応
大きく分けて2つやり方があると思います。
1. Apollo Client を SSR/CSR で完全に分離する
apolloClient.ts を SSR 用と CSR 用で完全に別ファイルに分けるアプローチです。そもそも SSR 用と CSR 用のインスタンスが同一ファイルに同居していたことが問題の発端だったので、これを根本から断ち切ることになります。SSR 用のファイルでは servicex を自由に import でき、CSR 用のファイルには Node.js 依存が一切含まれなくなります。ただし、ErrorLink やトークンリフレッシュなど SSR/CSRで共有しているロジックが多いため、変更差分が大きくなる点と、共有ロジックをどう管理するかという設計上の課題が残ります。
2. フロントエンドのプロジェクトを monorepo 化して分岐用パッケージを用意する
monorepo 構成にして、packages/ 配下に SSR/CSR の分岐を担うパッケージを用意するアプローチです。環境依存のモジュール解決を専用パッケージに閉じ込めることで、アプリケーション側のコードはどちらの環境で動いているかを意識せずに import できるようになります。構成がしっかり整えば堅牢ですが、monorepo のセットアップやパッケージ間の依存管理など初期コストが高く、既存プロジェクトへの導入ハードルは高めです。
おわりに
Next.js で SSR と CSR が共存する環境では、「SSR でしか使わないはずのコードが CSR バンドルに含まれる」という問題に遭遇しやすいです。特に Apollo Client のように SSR/CSR で共通の設定ファイルを持つ場合、Node.js 専用の依存が意図せず混入します。この問題を解決するには、ビルド時にモジュールの解決先を切り替えるアプローチが有効であり、webpack の NormalModuleReplacementPlugin は既存コードへの変更を最小限に抑えつつ、SSR/CSR で異なる実装を読み込ませることができます。
もし同じ問題に直面したら、まずはライブラリの package.json に exports フィールドが設定されていないか確認してみてください。設定されていなければ、今回紹介した .server-or-client パターンを検討してみると良いかもしれません。
この記事が、SSR/CSR 間のモジュール分離で悩んでいる方の参考になれば幸いです。