TECH PLAY

Jest

イベント

該当するコンテンツが見つかりませんでした

マガジン

該当するコンテンツが見つかりませんでした

技術ブログ

こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の7日目の記事です。 EGP Code は、ランディングページ(LP)を AI と作る社内向けのエディタです。作成背景については AI と作る HTML ベースの LP エディタ EGP Code を内製した理由 という記事で紹介しました。本記事では、その内部で動いている 4 つの仕組みを紹介します。 EGP Code が扱うのは、HTML と状態や動きを担う少数の <egp-*> (独自の Web Components)を混ぜた 1 枚のページです。 <section class="p-6 text-center"> <h1>春のキャンペーン</h1> <p>応募受付中</p> <egp-button>応募する</egp-button> </section> この HTML を AI エージェントとの対話やエディタで編集し、プレビューで確認して公開します。紹介する 4 つの仕組みは、(1) エージェントの再帰ループ、(2) Firestore を介したリアルタイム反映、(3) ブラウザだけで完結するテストランナー、(4) プレビューと HTML を結ぶ対応表です。 1 つの指示がユーザーから届いてプレビューに反映されるまでの流れと、それぞれの仕組みが効く場所は次のとおりです。 仕組み 1: 文脈とツールを束ねるエージェントの再帰ループ 「フォントサイズを 24px にして」「文字を太くして」のように特定の要素への簡単な指示なら、その要素を特定して CSS を更新したり文言を調整したりするだけなので、ほぼ 1 回の操作で終わります。一方で複数の要素にまとめて指示したり、API を使った画面やテストを作ったり、Lint・テストのエラーを直したりする場合は、推論とツール実行を何度か往復することになります。 エージェントは「推論 → ツール実行 → 結果を会話履歴に追加 → 再び推論」という再帰ループで動きます。ここでいうツールとは、AI が必要と判断したときに呼べる関数の定義と説明のことです。HTML を書き換える、ファイルを読む、Lint で検証する、テストを走らせる、といった操作をツールとして用意しておき、モデルがその中から必要なものを選んで呼び、戻り値を見て次の手を考えます。 1 回の入力に対して 4 つの情報をまとめて渡します。システムプロンプトで AI エージェントに役割やコード生成のルールといった前提を与え、それにユーザーの指示と選択要素と現在の HTML、これまでの会話履歴、そして使えるツールの一覧を教えます。このうち現在の HTML・仕様・テストといったページの状態は、種類ごとに XML タグで区切ってまとめます。 会話履歴の持ち方は、開発当初 OpenAI API 側に任せていました。しかし全社的に ZDR(Zero Data Retention) を適用することになり、プロバイダ側に会話を残せなくなったため、いまは履歴をすべて自前で記録し、毎回のリクエストに載せて送るステートレス方式にしています。履歴の肥大化を防ぐため、一定量を超えたら要約させてコンテキストを圧縮しています。 // 1 ラウンドの推論 const stream = await client.responses.stream({ ...args, input: sessionBuffer.getItems(), // 自前で管理している履歴全体を送る previous_response_id: undefined, store: false, // プロバイダ側に会話を保存させない }); ループの工夫をいくつか紹介します。 1 つ目は、ツールの失敗の扱いです。ここでいう失敗とは、 apply_patch の差分が当たらない、Lint がエラーを返す、テストが落ちるなど、ツールが期待どおりに完了しなかった状態を指します。こうした失敗ではループを止めず、エラーの内容をそのまま結果としてエージェントに返すことで自己修正させています。 2 つ目は、 find_skill ツールによる情報の出し分けです。社内 API の使い方などのドキュメントは、最初は ID と一行説明の一覧だけを見せておき、本文は必要になったタイミングで読み込みます。たとえば「商品一覧を表示したい」という指示が来ると、エージェントはまず一覧から関連しそうなものを探し、 find_skill でそのドキュメント本文を取得します。エージェントは取得したドキュメントを読み、正しい引数で API を呼びます。 この推論とツール実行の往復を繰り返し、最後にエージェントが apply_patch で HTML を差分更新すると 1 つの指示が編集として完成します。 ループの途中で方向を変える Real-time steering ここまでは、1 つの指示を最後まで処理してから次を受け取る前提でした。ですが実際には、処理の途中で「やっぱり色は青にして」と方針を変えたり、「ついでにフッターも直して」と指示を足したくなることがあります。完了を待たずに割り込みで指示を足し、走っているループに後から反映する仕組みを用意しています。こうした仕組みは Real-time steering とも呼ばれます。 ユーザーが処理中に指示を足したときの流れは、次のようになります。 エージェントが処理中(画面がローディング中)にユーザーがメッセージを送ると、クライアントはそれを通常の指示ではなく、割り込みメッセージとして送信します。サーバは受け取った割り込みメッセージを、 Firestore の通常の会話履歴とは別のサブコレクションに、いま走っているリクエストの ID を添えて書き込みます。エージェントは、自分が処理しているリクエストの ID に一致する割り込みだけを読み取ります。 // 自分のリクエスト宛の割り込みメッセージだけを読み、読んだら消す const consumeSteeringMessages = async (conversationId, requestId) => { const snapshot = await steeringRef .where('requestId', '==', requestId) // このリクエスト宛だけを対象にする .orderBy('timestamp', 'asc') .get(); const messages = snapshot.docs.map((doc) => doc.data()); await deleteDocs(snapshot.docs); // 読んだら消す return messages; }; ID で絞るので別のリクエスト宛の割り込みを拾うことはなく、読んだら消すので同じ指示が二重に効いたり取りこぼしたりすることもありません。処理のループに差し込むため、割り込みが来たときにエージェントが何をしようとしていたかで、対応が 2 つに分かれます。 1 つ目は、ツールを呼ぼうとしていた場合です。そのツールを実行せず、戻り値の代わりに「ツールは実行していません。処理中に新しい指示が届きました」という内容を返します。 // ツール実行の直前に割り込みを確認する const steering = await consumeSteeringMessages(conversationId, requestId); if (steering.length > 0 && toolCalls.length > 0) { for (const toolCall of toolCalls) { // ツールは実行せず、戻り値の代わりに割り込みを差し込む buffer.pushMessage({ role: 'tool', tool_call_id: toolCall.id, content: '[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...', }); } continue; // 計画を立て直すため、もう一度推論へ } 2 つ目は、ツールを呼ばずに、ユーザーへの返信メッセージを作り終えていた場合です。本来ならこれを見せてループが終わるところですが、割り込みが届いたので止めるべきツールがありません。この返信はまだ画面に出していないので破棄し、直前のユーザーの指示に割り込みメッセージを足して送り直すことで、続きの作業を依頼します。 // ツールがない場合は、画面に出していない返信を捨てて指示を足す if (steering.length > 0 && toolCalls.length === 0) { buffer.pop(); // まだ画面に出していない返信を捨てる const priorUserTurn = buffer.pop(); // 直前のユーザーの指示を取り出す buffer.pushMessage({ role: 'user', content: `${priorUserTurn.content}\\n[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...`, }); continue; } いずれの場合も、すでに実行した副作用を巻き戻すわけではなく、これから実行するはずだったツールを止めたり、まだ画面に出していない返信を捨てたりして、計画を組み直しています。 仕組み 2: Firestore を指示の受け渡し場所にしたリアルタイム反映 仕組み 1 で見たように、エージェントは複数のツールを往復させて指示に応えるため、編集には時間がかかることがあります。処理が終わるまで画面が何も更新されないと、利用者は反映されたかどうか分からないまま待つことになります。 そこで Firestore SDK を利用して、 変更をリアルタイムに受け取れる ようにしています。サーバ側は 1 つの操作が終わるたびにその内容を Firestore へ書き込み、ブラウザ側はそれをサブスクライブして即座に検知・反映します。これで自前で WebSocket を張らずに、編集の途中経過をそのままプレビューへ反映できます。 エージェントが何かを書き換えると、サーバは会話ごとのコレクションに、次のようなドキュメントを 1 件追加します。 { "status": "PENDING", "requests": [ { "action": "setHtmlSchema", "payload": "<body> ...更新後の HTML... </body>", "reason": "ユーザーの依頼を反映" } ] } ブラウザ側の JS は、Firestore の SDK( onSnapshot )でこのコレクションをサブスクライブしており、 PENDING のドキュメントが届くと requests の各操作をエディタの状態へ反映します。たとえば setHtmlSchema なら、エディタが表示している HTML を新しいものに置き換えて、プレビューを再描画します。 ブラウザが requests の操作を反映し終えると、その JS が status を COMPLETED に書き戻します。HTML 差し替えなどの「反映だけ」のアクションは、投げたら終わりで結果を待ちません。一方でテスト実行のように結果が必要なアクションでは、ドキュメントが COMPLETED になるまで一定間隔で読み直して、書き込まれた結果を取り出します。取り出した結果はツールの戻り値としてエージェントに返り、それを見て次のツール呼び出しを決めます。 仕組み 3: ブラウザだけで完結するテストランナー 応募ボタンを押したときの API 呼び出しやその結果に応じた表示の切り替え、リンクによる画面遷移といった LP の動作を手動で確認するのは手間がかかります。そこで EGP Code では、こうした動作をテストで確かめられるようにしています。 テストはブラウザ上のエディタで直接書いたり、AI に書かせたりできます。これらのテストは、サーバや CI ではなく、プレビューと同じブラウザの中で実行します。ただし Jest や Vitest は Node.js 上で動くツールなので、そのままブラウザには読み込めません。そこで test / it / describe / expect を提供する小さなランナーを自作しました。アサーションは単体で使える @vitest/expect 、DOM 操作は Testing Library をそのまま利用しています。 // 自作の test 関数でテストを定義する test('エントリーボタンで API が呼ばれる', async () => { // Testing Library でユーザーのクリックを再現する await userEvent.click(screen.getByText('エントリー')); // @vitest/expect で結果を検証する expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが社内 API を呼ぶこともありますが、本番に飛ばすわけにはいきません。そこで iframe 内で window.fetch を差し替え、リクエストはすべてモック関数に通します。モックしていない呼び出しはエラーになるので、本番へ漏れることはありません。 テストの実行は、この iframe を作るエディタのページ(ホスト)と iframe の postMessage のやり取りで進みます。 ホストは iframe を作ってテスト用 HTML を流し込み、iframe 側の初期化( window.fetch の差し替えなど)が済むのを待ってから実行を指示します。先に指示が届くと取りこぼすため、必ず「準備完了」を待つようにします。結果は仕組み 2 のアクションでエージェントへ戻ります。失敗していれば、仕組み 1 で触れた自己修正がここで働き、内容を読んで実装を直してもう一度走らせます。 仕組み 4: プレビューの要素と HTML の位置を結ぶ対応表 ここまではエージェント主導の編集を見てきましたが、人が直接手を入れる場面もあります。Code タブを開くと Monaco エディタで HTML を直接編集でき、プレビューでリアルタイムに確認できます。プレビューの要素をクリックしてエージェントへ指示を出したり、Monaco の対応行へジャンプするには、「プレビューの要素と HTML 上の位置」を対応づける仕組みが必要です。 HTML をパースして各要素へ data-egp-src-id (公開ページには残らない内部 id)を注入してプレビューを描画することで、これを実現しています。 <section data-egp-src-id="src-10"> <h1 data-egp-src-id="src-11">春のキャンペーン</h1> <egp-button data-egp-src-id="src-42">応募する</egp-button> </section> プレビュー上で「応募する」ボタンをクリックすると src-42 が取れます。クリック先が Web Components の内部要素などで id を持たない場合もあるため、 closest で祖先方向に遡って最寄りの data-egp-src-id を探します。 取得した id はエディタが各要素に振った内部 id で、対応表を引くキーになります。対応表はパース時に作る「id → その要素の HTML 上の位置」の Map で、id を引くと文字列オフセットやタグ名が取れます。 // 対応表(id → HTML 上の位置とタグ) Map { 'src-10' => { range: { startOffset: 0, endOffset: 130 }, tagName: 'section' }, 'src-42' => { range: { startOffset: 64, endOffset: 110 }, tagName: 'egp-button' }, // ... } クリックされるのは描画後の DOM 要素で位置を持つのはこの対応表です。要素に振った data-egp-src-id をキーにすることで、両者を突き合わせています。この対応表には 2 つの用途があります。1 つ目が Monaco エディタへのジャンプです。 // 対応表から位置を取得 const range = await mappingApi.findRangeById('src-42'); // オフセットを Monaco の行・列に変換 const pos = model.getPositionAt(range.startOffset); // その行をエディタの中央に表示してカーソルを移動 editor.revealPositionInCenter(pos); 取得した id を元に対応表から文字列オフセットを取り出して Monaco の行・列に変換します。その行をエディタ中央にスクロールしてカーソルを移動することでジャンプさせています。 もう 1 つが AI への指示です。 data-egp-src-id が付いているのはプレビュー用に描画した HTML だけで、エージェントが読み書きする公開用の HTML には付いていないため、id をそのまま渡しても対応する要素は見つかりません。そこで id は対応表を引くキーとしてのみ使い、エージェントには、そこから取り出した要素の HTML・タグ名・行番号をユーザーの指示文と一緒に XML へまとめて渡します。 <annotated_elements> <annotation> <tagName>egp-button</tagName> <snippet><egp-button class="..."></snippet> <lineNumber>10</lineNumber> <instruction>色を赤にして</instruction> </annotation> </annotated_elements> エージェントはこれらを手がかりに HTML の中から対象要素を見つけて指示に対応します。このように指示を XML タグで構造化する書き方は、 Anthropic のプロンプトのベストプラクティスの一つ としても紹介されています。 おわりに 一見シンプルに見える AI エディタですが、実際に体験を作ろうとすると、待ち時間が長い、途中経過が見えない、どこを編集したか分からないといった小さなつまずきが積み重なります。EGP Code では、そのつまずきを 1 つずつ潰すために、ここまで紹介した仕組みを裏側で積み上げてきました。AI を組み込むときほど、モデルの手前にある体験と安全性を設計することが重要だと感じています。本記事が、AI を組み込んだプロダクトづくりの参考になれば幸いです。 次の記事は@mikupoさんです。引き続きお楽しみください。
はじめに こんにちは!カイポケコネクトの開発推進チームでエンジニアをしている @_kimuson です。 私たちのチームでは、開発体験の向上や今後の拡張に備えて大規模なフロントエンドアプリケーションのマイクロフロントエンド化を進めています。 アプリ分割については下記の記事で紹介していますので、よろしければ合わせてご参照ください。 アプリ分割の一環としてpnpm workspaceを使ったモノレポ構成を採用しているのですが、internal packageにおけるpeerDependenciesの扱いが課題になりました。 この記事では、pnpm workspaceにおけるpeerDependenciesの問題とその解決策について整理してみます。同じような構成で開発している方の参考になれば幸いです。 pnpm workspace と peerDependencies、何が問題なのか まずは問題の背景から説明します。 pnpm workspaceでは workspace:* プロトコルを使うことで、ローカルパッケージ間の依存をシンボリックリンクで解決してくれます。これがとても便利で、パッケージ内のファイルを編集すると即座に反映されますし、watchプロセスも不要なので開発環境が重くなりません。 典型的な構成はこんな感じです: // apps/main/package.json { " dependencies ": { " react ": " ^18.0.0 ", " shared-ui ": " workspace:* " } } // packages/shared-ui/package.json { " peerDependencies ": { " react ": " ^18.0.0 " } } apps/main が shared-ui に依存していて、 shared-ui はReactをpeerDependenciesとして宣言しています。ごく普通の構成ですね。 シンボリックリンクが引き起こす問題 UI Library等ではよくある普通の構造だと思いますが、workspaceでのinternalパッケージでは致命的な問題があります。 シンボリックリンクでパッケージを参照すると、Node.jsのモジュール解決の仕組み上、それぞれのパッケージが 異なるライブラリの実態を参照してしまう 、ということです。 シンボリックリンクにより shared-ui を参照していますが、それぞれが異なるreactインスタンスを参照してしまいます。 Node.jsはモジュールを解決するとき、呼び出し元のファイルから見て近いディレクトリから順番に node_modules を探していきます(参考: Node.js Documentation - Loading from node_modules folders )。 shared-ui のソースコードから import React from 'react' すると、まず packages/shared-ui/node_modules/react を見つけてしまうわけです。 peerDependenciesは本来「利用側と同じインスタンスを使ってね」という意図で宣言するものですが、シンボリックリンクだと期待通りの解決になりません。 実際に問題が起きるケース とはいえ、すべてのpeerDependenciesで問題が起きるわけではありません。問題が顕在化するのは、基本的にパッケージが グローバルな状態を持つ 場合です。 たとえば、わかりやすい例として単純なカウンターパッケージを考えてみます: // counter.ts let count = 0 ; export const addCount = () => { count++; } ; export const getCount = () => count; このパッケージが shared-ui にpeerDependenciesとして追加されていると仮定して、 shared-ui から addCount() を呼んでも、 apps/main から getCount() を呼ぶと0が返ってきます。別のインスタンスの別の変数を見ているからですね。 Reactも同様で、 useContext や useMemo などグローバルな状態に依存する機能を使うとエラーが発生します。一方、date-fnsやes-toolkitのような純粋な関数だけを提供するパッケージは、同じ実装が2箇所に存在するだけなので動作には問題ありません。 解決策 この問題に対するアプローチは大きく2つあります。いずれも peerDependencies を同じモジュールの実体に解決させる という方向性は同じです。 解決策1: バンドラーやテストフレームワークで依存解決をオーバーライドする 1つ目は、依存解決が行われる各ツールで、モジュールの解決先を上書きする方法です。 Jestの場合はこんな感じで設定します // jest.config.ts import { createRequire } from 'node:module' ; const require = createRequire(import.meta. url ); const reactPath = require.resolve( 'react' ); const jestConfig = { moduleNameMapper : { '^react$' : reactPath, } , } ; export default jestConfig; webpackを使うNext.jsやStorybookでも、同様に resolve.alias で上書きできます。Viteでも dedupe オプションが提供されています。 メリット: シンボリックリンクの利点(即座に反映、watch不要、軽量)を維持できる 必要な箇所だけパッチを当てられる デメリット: Next.js、Storybook、Jest/Vitestなど、依存解決を行うすべてのツールで設定が必要 新しいツールを追加するたびに設定を追加しないといけない 潜在的な問題は残る Ex. 実はグローバルな状態を持っていてロジックが壊れていることに後から気づく Ex. 同じコードが重複してバンドルに含まれてしまう 解決策2: pnpm の dependenciesMeta.*.injected を使う 2つ目は、pnpmが提供する injected オプションを使う方法です。 解決策1がパッチを充てるような対策だったことに対して、こちらは根本解決になります。 // apps/main/package.json { " dependencies ": { " react ": " ^18.0.0 ", " shared-ui ": " workspace:* " } , " dependenciesMeta ": { " shared-ui ": { " injected ": true } } } これを設定すると、pnpmは .pnpm ディレクトリ内に node_modules を持たないパッケージのクローン を作成します。 ディレクトリ構造は下記のようになります。 ./ ├── apps │ └── main │ └── node_modules │ ├── .pnpm │ │ └── <shared-ui-clone> │ │ ├── src (shared-ui のコピー) │ │ └── node_modules (空) │ └── @my-pkg │ └── shared-ui --> .pnpm/<shared-ui-clone> ├── packages │ └── shared-ui └── package.json apps/main からはこのクローンを参照するようになるので、 shared-ui のコードからReactをimportしても、クローン側の node_modules は空であるため親ディレクトリをたどり、 apps/main/node_modules/react へ解決されます。 つまり、 apps/main のコードと shared-ui のコードが同じreactインスタンスを参照するようになります。 見ての通り根本的な解決であり、理想的に見えますが開発体験が致命的に悪いという問題を含んでいます。 injected による開発体験の劣化と融和策 injected を使うと、 packages/shared-ui/src とそのクローンである apps/main/node_modules/.pnpm/<shared-ui-clone>/src は基本的にファイルごとにハードリンクで同期されます。 したがってファイルが追加されたり、削除されたりする場合には同期されません。 また、特定条件下でハードリンクではなく単なるコピーが行われるというUndocumentedな挙動があり、我々のプロジェクトではこの条件( shared-workspace-lockfile=false , postinstall あり)を満たすためコピーに寄って作成され、変更すら同期されないという状態でした。 起票したIssue: https://github.com/pnpm/pnpm/issues/9828 この問題を補完するために、 pnpm-sync-dependencies-meta-injected というツールがあります。これを使うと、開発中にソースコードの変更をwatchしてハードリンクを更新してくれるので、injectedを使いながらも快適な開発体験を維持できます。 ちなみにpnpm v10では syncInjectedDepsAfterScripts という公式オプションも追加されています。任意のスクリプト実行後にハードリンクを再同期してくれるもので、将来的にはこちらを使う選択肢もありそうです。 ただこれで全部解決だよね!ということでもなく「node_modulesを削除してpnpm iしなおさないと更新されない状態に定期的に陥る」といった問題が実際に発生しており、開発体験としては非常に悪い状態が残っています。 結局どちらを採用すればよいのか 少なくとも現在(2025年12月)ではどちらかが完璧な解決策にはなっておらず、トレードオフを考慮して選ぶ必要があります。 観点 解決策1(オーバーライド) 解決策2(injected) 開発体験 ◎ △ 設定の煩雑さ・手間 × ◎ 根本解決 × ◎ バンドルサイズ △(重複の可能性) ◎(重複なし) 可能であれば根本解決であるinjectedに寄せていきたい気持ちはありつつ、開発体験が悪すぎるので現状はオーバーライドに寄せています。 overrides 方式では解決できない問題もある 基本開発体験を優先してoverridesに寄せていますが、一部の共通パッケージではoverridesで解決できない問題があり、injectedも利用しています。 具体的には型解決の問題です。 型解決についてはpeerDependenciesの実態が異なるものに解決されていたとしてもバージョンさえ揃っていれば構造的部分型で型チェックされるため基本的には問題は起きません。 import { User } from 'peer-dep-pkg' import { getUser } from '@my-pkg/dep' const user: User /* node_modules/peer-dep-pkg */ = getUser() /* packages/dep/node_modules/peer-dep-pkg */ のように実態が異なっていても構造は同一であるため、型エラーにはなりえません。 弊プロダクトの場合はpnpm catalogを使って依存を管理しているため、バージョンは固定する運用をしているのでバージョン違いも起きません。 https://pnpm.io/ja/catalogs 一方、TypeScriptでも「classを使っておりprivateプロパティを持つ場合」では例外的に総称型で型チェックされるケースが存在し、そういった型が使われているライブラリがpeerDependenciesに存在すると型チェックは適切に行えません。 // ライブライ側のコード例 class ApiClient { private cache : unknown ; // ... } import { ApiClient } from '@my-pkg/peerDep' import { createApiClient } from '@my-pkg/dep' const apiClient: ApiClient /* node_modules/peer-dep-pkg */ = createApiClient() /* packages/dep/node_modules/peer-dep-pkg */ ; // => 実態が異なるため総称型でチェックされ型エラー この問題は解決のワークアラウンド的に回避も難しいので、一部のパッケージでは開発体験の劣化を許容してinjectedを採用しています。 終わりに pnpm workspaceにおけるpeerDependenciesの問題と解決策について整理しました。 シンボリックリンクによるモジュール解決の仕組み上、peerDependenciesが異なるインスタンスを参照してしまう問題がある グローバルな状態を持つパッケージ(Reactなど)や総称型になる型を公開するパッケージで問題が顕在化する 解決策としては「各ツールでオーバーライド」か「injectedオプション」の2つ 正直、overridesも設定が煩雑すぎるしワークアラウンドであること、injectedも体験が悪くてしっくりは来ていないのですが背景理解と方針検討にも苦労したので、同じようなworkspace化等に取り組んでいる方の参考になれば幸いです! また、pnpmのリポジトリでのこの問題は議論されているので、injected(根本解決)ベースでより開発体験も維持できるソリューションが出てくると良いなと思っています。 https://github.com/orgs/pnpm/discussions/3938
はじめに こんにちは!カイポケコネクトの開発推進チームでエンジニアをしている @_kimuson です。主にフロントエンドを中心に開発生産性の向上に取り組んでいます。 今回は、カイポケコネクトのフロントエンドを単一のNext.js構成からマイクロフロントエンド化した話を紹介します。 スパンで言うと提案をしてから9か月ほど経っているのですが、ようやく形になってきたので方針や試行錯誤した知見を共有できればと思います。 背景・元々のアーキテクチャ まず、元々の構成を簡単に説明しておきます。 カイポケコネクトのフロントエンドは、GraphQL Federation *1 を行うBFF *2 Serverに対して巨大なフロントエンドが建っている構造です。 Next.jsを使っていますが、SSR *3 は行わず静的な構成です。Pages RouterでStatic Buildした成果物をS3でホスティングするだけのシンプルな形ですね。 組織構造としては、ストリームアラインドチーム *4 がNext.js内のページで区切られた小さいアプリケーションごとにオーナーシップを持っています。 例えば図のapp1のチームは主に src/pages/app1 , src/services/app1 のオーナーシップを持つような形です。 分割のモチベーション もともとはミニマムに単一のNext.jsで開始していたこのプロダクトですが、組織拡大・時間経過とともに肥大化を続けており、ペインが出てきている状況でした。 課題1: 各チームごとにバリューストリームを持ちたい まずそれぞれのアプリケーションの開発はチームが分かれているので、当然それぞれのチームでバリューストリームを持ち、リリースをしていきたいのですがビルドが一括になる都合上個別でのデプロイを行うことができませんでした。 結果、2週間毎にまとめて足並みをそろえてリリースを行う「リリーストレイン」を長らく実施していましたが もっと高頻度にリリースをしていきたい アプリ単位でQAやリリースを行って行きたい と言った需要が大きくなっていきました。 課題2: CI やローカル環境の肥大化 本来自チームとは関係ない・全チームのソースコードが含まれているパッケージになってしまっているので 変更に関係ない対象のCI(test, build, lint, 型チェック)を動かす必要がある ローカルで全部入りのNext.js Dev Serverを立てる必要がある と言った状態でした。 今後もアプリの小グループやコードベースも増えていくので今の構成のまま進んで大丈夫か?という懸念が出つつありました。 マイクロフロントエンドで解決を目指す これらの課題に対して、Verticalにフロントエンドを分割する構成に移行することで解決を目指しました。 アプリごとに独立したNext.jsアプリケーションを持てるようにします。 これにより ローカル環境で自分が触らないアプリは共有環境のリソースに接続できる CI/CDも分割され、アプリ単位で必要なCIに絞って回すことができるようになる アプリのデプロイ単位を分割できるため、チームごとに自分のアプリだけデプロイすることが可能になる という形でペインが解消されていく構想で開始しました。 実現方法 分割の実施には複数のアプローチを検討しましたが、分割すること自体が非常に大きな取り組みでありスコープを極力絞ったミニマムな方針で分割を行いました。 例えばアプリごとにサブドメインを分ける等も考えられましたが アプリのパスは変えない( /app1 ならapp1のNext.jsが動くだけ、という構造) パスを変えないので旧パス・新パスのリダイレクト等も不要 これまで同様単一のs3バケットにデプロイする形を維持 とすることで、パッケージを分割する以外の関心をあまり気にせず進められるようにしました。 basePath を活用したインフラ構成がほぼ変わらない方法 Next.jsには basePath オプション が存在しており、サブパスにおいて配信する構成に対応しています。 これを使ってそれぞれのアプリを basePath ありでビルドし、成果物を結合してS3にアップロードします。 # 各アプリのビルド成果物 apps/ ├── app1/out/ # basePath: /app1 でビルド │ ├── _next/ │ └── index.html ├── app2/out/ # basePath: /app2 でビルド │ ├── _next/ │ └── index.html └── app3/out/ # basePath: /app3 でビルド ├── _next/ └── index.html ↓ cp -r で結合 # S3にアップロードする成果物 dist/ ├── app1-path/ │ ├── _next/ │ └── index.html ├── app2-path/ │ ├── _next/ │ └── index.html └── app3-path/ ├── _next/ └── index.html 静的ホスティングでの Dynamic routes 対応 上記で基本的には期待通り動くのですが、Dynamic routesの解決に関しては追加の対応が必要です。 そもそも分割関係なく一般的にNext.jsの静的ホスティングではDynamic Routesが動作しないのでワークアラウンドを行う必要があることが知られています。 詳細な方法はインフラ等にも寄るのでまちまちですが、概ねこういう対応が必要です。 インフラ側: /posts/10 のようなアセットが存在しないパスへのリクエストでも /404.html 等を返すようにする フロントエンド側: /404 等受けたFE側で window.location.pathname ( /posts/10 ) を確認し、存在するルートあれば router.replace してフォールバックする アプリ分割すると当然アプリごとの /404 からしかソフトナビゲーションで正規のパスにフォールバックできませんから、個別の /404 に流す必要があります。 我々の場合は、CloudFront Functionsで下記のような対応を入れることでDynamic Routesに上手く対応しています。 静的アセットは正規表現でマッチさせてそのまま帰す それ以外はCloudFront Functionsが各アプリのパスを知っており、 /app1-path → /app1-path/404.html を返す、というような処理を生やす これにより、分割して結合したビルド成果物をs3に配信する形でDynamic Routes含め正常に動かすことができるようになりました。 モノレポの管理 続いて、ローカルやCIでのパッケージ管理についてです。 モノレポ管理はpnpm workspace + Turborepoの構成を採用しています。 pnpm workspaceは他のパッケージマネージャーと比較してもワークスペース周りの機能が充実しています。 基本的にはワークスペースプロトコルを使用し、内部パッケージ間の依存を解決しています。ワークスペースプロトコルを使用すると依存元のnode_modules/pkg-name部分がパッケージの実態へのシンボリックリンクになるため、ホットリロード等がほぼ同一パッケージ内で依存していた場合と変わらないような体験で利用できます。 ./ ├── packages/ │ └── shared/ # 実際のパッケージの実体 │ ├── package.json │ └── src/ │ └── index.ts │ └── apps/ └── app1/ ├── package.json # "shared": "workspace:*" と記述 └── node_modules/ └── shared/ # → ../../packages/shared へのシンボリックリンク また、パッケージを跨ぐスクリプトの管理にTurborepoを採用しています。 Turborepoでは --affected というオプションが提供されており、例えば turbo run build --affected と実行するとgitのdiffから依存関係を含めて実行する必要があるパッケージを計算し、実行してくれます。 また、我々はまだそういう構成が必要になっていないので利用していませんが、「ビルドを実行するには依存するパッケージがビルドされている必要がある」というような依存関係も定義できるのでここういった実行すべきタスクの管理がお任せできて便利です。 internal package における TypeScript の型解決 internalでないパッケージではビルドを行い d.ts と .js を公開して利用させる構造が一般的です。 // 一般的な npm package の公開方法 { "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "require": "./dist/index.cjs", "import": "./dist/index.mjs" } } } ただしinternal packageでは一々共通パッケージでビルドしていると開発体験が悪いので、 .ts ファイルを直接公開し、実際にトランスパイルを行うのはアプリ側にしています。 // TypeScript ファイルを直接公開する { "type": "module", "exports": { ".": "./src/index.ts" } } 基本この形で開発者体験を維持して依存解決をしていますが、一部ビルドをしたいパッケージもワークスペース内に存在しているのでそちらについてはcustom conditionを使って内部でのみ .ts に解決させたりもしています。 この辺りは以前記事を書いているのでよければあわせてご参照ください。 直接 .ts を公開するために気をつけること 体験が圧倒的に良いので .ts の直接公開がおすすめですが、いくつか気をつけておくべきポイントがあります。 まずは、 .ts を公開するということは複数のパッケージから公開されたtsファイルの型チェックが行われるということです。 そのため、極力パッケージ間の型チェックに関するtsconfigのオプションを統一しておくことが望ましいです。我々の場合は共通のtsconfigを packages/tsconfig として用意し、これをextendsして必要な箇所だけ上書きする構造にしています。 { "extends": "@my-pkg/tsconfig/base.json", "compilerOptions": { // ... 上書きする設定 } } 次に同様の理由でパッケージのバージョンを揃えておくことが望ましいです。 アプリごとのTypeScript本体のバージョンが異なっていたり、依存のバージョンが異なっていると一方では通る型チェックがもう一方では通らないと言ったことがありえます。 pnpmではCatalogsというワークスペース内で利用するバージョンを揃える機能が提供されているのでこれを使うと統一を簡単に実現できます。 # pnpm-workspace.yaml packages : - apps/** - packages/** catalog : 'react' : 19.0.0 catalogMode : strict cleanupUnusedCatalogs : true // package.json { " dependencies ": { " react ": " catalog: " } } 共通のアプリケーションコードをどうするか問題 app1とapp2で「共通で利用しているコード」は、パッケージに切り出す必要があります。いわゆるcommonやutilsという名前がつきがちなコード郡ですね。 まず理想形を言うとちゃんと責務ごとにパッケージを分けていくのが良いと思います。 一方、現実的にはリファクタコストが大きくて分割のコストが大きくなってしまうため、どかっとまとめて単一のsharedなパッケージとして切り出すことにしました。 これはパッケージ間の循環参照を許容しないためです。 例えば auth パッケージと test パッケージを用意して、testではauthにある型が必要、authではテストするのにtestパッケージのユーテリティが必要となると相互依存の関係になってしまうことになります。これを許容してしまうとビルド等の依存関係を正しく解決できますか?とか、型やパッケージ自体の依存解決は問題ないか?等と考えることが増えるため許容しないことにしています。 上記を基本方針としながら まずは単一のNext.jsアプリがワークスペースの中で動く構成 命名からcommonのようにわかりやすく共通部分となっている箇所を切り出し、1のアプリがそこに依存する状態を作る 小さい・変更の少ないアプリから実際に移行しながら必要な箇所を特定して共通パッケージに切り出す という流れで共通コードを分離しながら分割を進めていきました。 結果 この記事を書いている2026年1月現在ですべてのアプリ分割は終わっていませんが、残すことあと1アプリとなりました。 現時点での結果をまとめようと思います。 開発環境で必要なアプリだけ起動できるようになった ビルド単位が別れたことで触るアプリケーションの next dev のみ立てれば良い形になりました。他のアプリに関しては共有環境のbuildをそのまま受け取るだけで良いので開発マシンにも優しいですし、他アプリ起因のトラブルが起きづらく成りました。 CI は turborepo --affected で必要な依存だけ実行 冒頭で紹介した通りTurborepoには --affected オプションがあり、変更に影響があるスクリプトだけ実行することができます。 CIの構築も手軽で、CI用のワークフローを用意して --affected でテスト等を実行するだけで必要なパッケージに絞ったテスト等が実行されるようになります。 ライブラリの段階移行がしやすくなった 副次的に狙っていたことではありますが、ライブラリのアップデートをアプリ単位で進められるようになりました。 コードベースが大きいので、密に依存しているライブラリのMajorアップデートや別ライブラリへの移行等は大変になりがちです。例えばJestはESM Support周りが辛いのでVitestへ移行をしているのですが、こういう話をアプリごとで実施できるようになりました。 ちなみに、前述した通り我々はパッケージのバージョンはすべてpnpm catalogで一元管理していますが、named catalogsを使って複数バージョンの共存も可能になっています。 # pnpm-workspace.yaml # 通常のカタログ catalog : react : 17.0.2 react-dom : 17.0.2 # Named Catalog catalogs : react17 : react : 17.0.2 react-dom : 17.0.2 react18 : react : 18.2.0 react-dom : 18.2.0 これでバージョンを一元管理しつつも、特定のパッケージだけMajorバージョンアップさせると言った対応が可能になっています。 https://pnpm.io/catalogs#named-catalogs 残っている課題:shared 肥大化問題 分割後に課題もいくつか残っているのですが、特に重要なので「sharedの肥大化問題」です。 まず、アプリケーションの共通部分を切り出したsharedのパッケージの変更ではホットリロードが効かないという問題があります。 これはpeerDependenciesを持つパッケージを workspace:* プロトコルで解決する際に起こってしまう問題であり、事情が複雑なのでこれ自体で記事を書いています。 また、ホットリロードの問題を置いておいてもsharedがファットだと結局変更が他チームに影響することが増えます。調整ごとの時間が増えてしまうので、組織的にも良くないと思っています。 対策としてsharedを減らしていく方向で進めています。 そもそも移行を優先してどかっと持ってきてしまったので デザインシステムとして昇華されていないが、ドメインの事情を持たないUI Patternはデザインシステムに持っていけないか? アプリを跨ぐ共通のUIは本当に共通にしないとダメなものか?コピーの方が望ましくないか? 等を検討し、ちゃんと用途ごとのinternal packageに分離してsharedを縮小していきたいと思っています。 まとめ 巨大なNext.jsアプリケーションをマイクロフロントエンド化した話を紹介しました。 ページパスやインフラ構成への影響を最小限に抑えたことで、現実的に分割を進められました。 おかげでCIや開発環境、依存ライブラリの段階的アップデートなどアプリケーション単位で実施できることが増えました。 まだ課題が残っているのは書いたとおりなので、引き続き改善を続けていきたいと思っています。 *1 : 別途のグラフを持つ複数のGraphQL Serverを束ねるGateway Serverを用意し、クライアントから統合された1つのグラフに対してリクエストを行うことができるアプローチ。 *2 : Backend For Frontend の略。フロントエンドとバックエンドの中間に配置されるサーバーで、フロントエンドから見て複雑なバックエンド呼び出しを隠蔽する等の責務を持ちます。 *3 : Server-Side Rendering の略。意味が揺れがちですが、ここでは「リクエストごとにサーバー側でHTMLを組み立てて返す構成」の意味で使用しています。 *4 : 書籍チームトポロジーにて紹介されているチーム分類の1つです。基盤を提供したり高度な専門領域を扱うチームと対比してビジネスドメインに沿ってプロダクトの開発を進めるチームを指します。

動画

該当するコンテンツが見つかりませんでした

書籍

該当するコンテンツが見つかりませんでした