`next dev` から最初のレスポンスまで:サーバーの起動とリクエストパイプライン
前提知識
- ›第1回:アーキテクチャとコードベースの読み方
- ›Node.js HTTP サーバーの基礎(createServer、リクエスト/レスポンスのライフサイクル)
- ›Node.js におけるプロセスフォークとワーカーパターンの理解
- ›TypeScript のクラス継承に関する基本的な知識
next dev から最初のレスポンスまで:サーバーの起動とリクエストパイプライン
next dev と入力して Enter を押してから、ブラウザが最初のレスポンスを受け取るまでの間に、実は驚くほど複雑な処理が走っています。プロセスフォーク、多層的な抽象化、遅延初期化、そしてリクエスト処理の根幹を担う約 3,050 行の抽象クラスが関わっています。このサーバーアーキテクチャを理解しておくことは、Next.js の内部をデバッグする際の必須知識であり、なぜそのような設計上の判断が下されたのかを知る手がかりにもなります。
CLI からサーバーへ:起動シーケンス
すべては packages/next/src/cli/next-dev.ts から始まります。nextDev 関数はまず parseBundlerArgs()(第1回で解説済み)を呼び出して使用するバンドラーを決定し、続いてプロジェクトのディレクトリを解決します。
ここで重要なアーキテクチャ上の判断が登場します。子プロセスフォークです。CLI のメインプロセスはサーバーを直接起動するのではなく、fork() を使って子プロセスを生成します。
sequenceDiagram
participant CLI as next dev (main process)
participant Child as Server (child process)
participant HTTP as HTTP Server
CLI->>CLI: parseBundlerArgs()
CLI->>CLI: preflight checks (sass, react versions)
CLI->>Child: fork('start-server.ts')
Child->>Child: startServer()
Child->>HTTP: http.createServer()
HTTP-->>Child: 'listening' event
Child->>Child: initialize() (router-server)
Child-->>CLI: IPC: { nextServerReady, port, distDir }
CLI->>CLI: Store port/distDir for telemetry
フォークは next-dev.ts#L323 で行われ、TURBOPACK、NEXT_PRIVATE_WORKER、Node オプションといった環境変数が渡されます。子プロセスは IPC メッセージでメインプロセスに通信し、nextWorkerReady を受けてサーバーオプションが送信され、nextServerReady によってポートのバインドと接続受け付けの開始が通知されます。
なぜフォークするのか——理由は2つあります。分離性と再起動可能性です。サーバープロセスがメモリ不足に陥ったり回復不能なエラーが発生した場合、メインプロセスが子プロセスを再起動できます。start-server.ts#L249-L265 にあるメモリ圧迫チェックにも注目してください——ヒープ使用量が 80% を超えると、子プロセスは RESTART_EXIT_CODE で終了し、親プロセスがそれを再生成します。
HTTP サーバーの生成とポートバインド
子プロセスの内部では、startServer() が HTTP サーバーを作成します。ここでは、遅延ハンドラーパターンと呼べる巧妙な実装が使われています。
let handlersPromise: Promise<void> | undefined = new Promise<void>(...)
let requestHandler: WorkerRequestHandler = async (req, res) => {
if (handlersPromise) {
await handlersPromise
return requestHandler(req, res)
}
throw new Error('Invariant request handler was not setup')
}
HTTP サーバーはすぐにリスニングを開始しますが、router-server の初期化が完了するまでリクエストはキューに積まれます。これにより、初期化に数秒かかる場合でも、ポートはできるだけ早くバインドされます。ブラウザが「接続拒否」を受け取ることはありません。
実際のサーバー生成は line 270-278 でシンプルな http.createServer()(開発環境の自己署名証明書があれば https.createServer())として行われます。EADDRINUSE が発生した場合には、ポート番号を最大 10 回インクリメントするリトライロジックが動作します。
Router-Server と Render-Server の分離
HTTP サーバーがリスニングを開始すると、router-server.ts の initialize() 関数が呼ばれます。ここでアーキテクチャの真髄が現れます——ルーティングとレンダリングを担う2層の分離です。
flowchart TD
HTTP["HTTP Server\n(start-server.ts)"] --> RouterServer["Router Server\n(router-server.ts)"]
RouterServer --> Config["Load Config"]
RouterServer --> FsCheck["Setup Filesystem Checker"]
RouterServer --> Compression["Setup Compression"]
RouterServer --> ResolveRoutes["Route Resolver"]
RouterServer -->|"Delegates rendering"| RenderServer["Render Server\n(render-server.ts)"]
RenderServer -->|"Lazy creates"| NextServer["NextServer\n(next.ts)"]
NextServer -->|"Lazy loads"| NodeServer["NextNodeServer\n(next-server.ts)"]
NextServer -->|"Or in dev"| DevServer["DevServer\n(next-dev-server.ts)"]
subgraph "Router Layer (always running)"
RouterServer
ResolveRoutes
end
subgraph "Render Layer (lazy, replaceable)"
RenderServer
NextServer
NodeServer
DevServer
end
router-server は設定を読み込み、ファイルシステムチェッカー(マニフェストを読み込んで存在するルートを把握するもの)を初期化し、ルートリゾルバーを作成します。render-server は遅延ラッパーとして機能し、レンダリングリクエストが来て初めて実際の NextServer をインスタンス化します。
この分離は、主に開発時のために設計されています。コードが変更されたとき、render ワーカーだけを破棄・再生成しても、ルーティングレイヤーは継続して動作し続けます。render server が再起動している間もミドルウェアは動き続けます。router-server.ts#L129-L137 を見ると、render server が LazyRenderServerInstance として保持されているのがわかります——これはオプションの instance プロパティを持ち、差し替え可能なオブジェクトです。
const renderServer: LazyRenderServerInstance = {}
開発モードでは、router-server は setupDevBundler() を通じて dev バンドラーもセットアップします。これにより、Turbopack または Webpack の HMR インフラが初期化され、ファイルの変更監視が始まります。
サーバークラスの階層
render-server が NextServer インスタンスを生成すると、コードベースの中で最も重要な抽象化——サーバークラス階層に入っていきます。
classDiagram
class Server {
<<abstract>>
+handleRequest(req, res, parsedUrl)
-handleRequestImpl(req, res, parsedUrl)
#run(req, res, parsedUrl)
-pipe(fn, context)
#renderToResponse(ctx)*
#loadComponents(page)*
#findPageComponents(params)*
#getRoutesManifest()*
hostname: string
nextConfig: NextConfigRuntime
distDir: string
buildId: string
}
class NextNodeServer {
+loadComponents(page)
+findPageComponents(params)
+getRoutesManifest()
-loadManifestWithRetries(name)
+serveStatic(req, res, path)
-sendRenderResult(req, res, result)
}
class DevServer {
+ensurePage(opts)
-getCompilationError(page)
-logErrorWithOriginalStack(err)
+getStaticPaths(params)
}
Server <|-- NextNodeServer : extends
NextNodeServer <|-- DevServer : extends
base-server.ts にある抽象クラス Server(約 3,050 行)は、ランタイムに依存しない設計になっています。Node.js を前提とせずにリクエスト処理パイプラインを定義しており、同じロジックが Edge 環境でも動作できる可能性を残しています。ルートマッチング、キャッシュ制御、ミドルウェアの適用、レスポンス生成——これらはすべてこのクラスが担います。
NextNodeServer は Node.js 固有の機能を追加します。マニフェスト読み込みのためのファイルシステムアクセス、gzip 圧縮、静的ファイル配信、そして IncomingMessage/ServerResponse のアダプテーションレイヤーです。
DevServer はさらに開発用の機能を上乗せします。ensurePage() によるオンデマンドページコンパイル、エラーオーバーレイとの統合、ソースマップのサポート、HMR との協調動作などです。
ヒント:
base-server.tsを読む際は、handleRequest()(line 872)とrun()(line 1737)に集中しましょう。この2つのメソッドがすべてを統括しています——handleRequestがエントリーポイントで、runが実際にレンダリングを実行する場所です。
NextServer ラッパーと遅延ロード
リクエストが NextNodeServer に到達する前に、NextServer という公開 API のラッパークラスを通過します。このクラスは遅延初期化を徹底的に活用しており、実際のサーバー実装(ServerImpl)は getServerImpl() を通じて初回アクセス時にロードされます。
const getServerImpl = async () => {
if (ServerImpl === undefined) {
ServerImpl = (
await Promise.resolve(
require('./next-server') as typeof import('./next-server')
)
).default
}
return ServerImpl
}
この遅延 require() には重要な意味があります。next-server.ts モジュールはマニフェストローダー、レンダリングエンジン、ルートマッチャーなど、巨大な依存ツリーを引き込みます。このインポートを遅延させることで、フルのレンダリングインフラが読み込まれる前でも、router-server は静的ファイルやリダイレクトといったシンプルなリクエストを処理し始めることができます。
ルート解決:URL からハンドラーへ
リクエストが router-server に到着すると、最初のステップはルート解決です。resolve-routes.ts(約 928 行)の getResolveRoutes() 関数がリゾルバーを生成し、リクエストをファイルシステムチェッカーとカスタムルートに照らし合わせて評価します。
flowchart TD
Request["Incoming Request"] --> BasePath["Strip Base Path"]
BasePath --> I18N["Locale Detection"]
I18N --> Headers["Apply Custom Headers"]
Headers --> Redirects["Check Redirects"]
Redirects -->|Match| RedirectResponse["301/302 Response"]
Redirects -->|No Match| Rewrites["Before Rewrites"]
Rewrites --> Middleware["Run Middleware"]
Middleware -->|Rewrite/Redirect| MiddlewareResult["Apply Middleware Result"]
Middleware -->|Pass-through| FsCheck["Filesystem Check"]
FsCheck -->|Static File| StaticServe["Serve Static"]
FsCheck -->|Route Match| AfterRewrites["After Rewrites"]
AfterRewrites --> RenderServer["Dispatch to Render Server"]
ファイルシステムチェッカー(filesystem.ts)は pages-manifest.json、app-paths-manifest.json、routes-manifest.json といったマニフェストを読み込み、既知のルートのルックアップテーブルを構築します。開発環境では、ページがコンパイルされるたびにこのテーブルが動的に更新されます。
ルートリゾルバーが処理する順序は次のとおりです:ベースパスの除去 → ロケール検出 → ヘッダー → リダイレクト → "before" rewrite → ミドルウェア → ファイルシステムチェック → "after" rewrite → フォールバック rewrite。この順序はビルド時に生成されるルートマニフェストで定義されています。
リクエスト処理パイプライン
リクエストがルート解決を通過して base-server.ts に到達すると、handleRequest() メソッドに入ります。ここでは実際の処理が OpenTelemetry のトレーシングでラップされています。
sequenceDiagram
participant Client as Browser
participant BS as BaseServer.handleRequest()
participant RM as RouteMatcherManager
participant RL as Route Module Loader
participant Render as renderToResponse()
Client->>BS: HTTP Request
BS->>BS: handleRequestImpl() - parse URL, normalize
BS->>BS: Check for data requests (_next/data/*)
BS->>RM: match(pathname)
RM-->>BS: RouteMatch { definition, params }
BS->>RL: loadComponents(match.page)
RL-->>BS: { Component, mod, DocumentComponent }
BS->>BS: run(req, res, parsedUrl)
BS->>Render: pipe(renderToResponse, context)
Render-->>BS: RenderResult
BS->>Client: HTTP Response (HTML or RSC payload)
handleRequestImpl() の内部では、URL の正規化、RSC 固有ヘッダーの除去、そしてこのリクエストが RSC(Flight)リクエストかフル HTML リクエストかの判定が行われます。その後、処理は run() に委譲され、run() は renderToResponse() を引数に pipe() を呼び出します。
ルートマッチングにはプロバイダーパターンが使われています。ルートの種別ごとにマッチャープロバイダーが存在します——AppPageRouteMatcherProvider、PagesRouteMatcherProvider などです。これらのプロバイダーはマニフェストを読み込んでマッチャーを生成し、DefaultRouteMatcherManager に登録します。リクエストが来ると、マネージャーはマッチャーを順番に試して最初にマッチしたものを返します。
マッチしたルートは loadComponents() でロードされ、React コンポーネント、ルートモジュール、関連メタデータが返されます。App Router のルートではローダーツリー(ネストされたレイアウト構造)が含まれ、Pages Router のルートでは getServerSideProps や getStaticProps 関数が含まれます。
ヒント: line 1755 の
pipe()メソッドは重要な分岐点です——ここで抽象メソッドrenderToResponse()が呼ばれます。各サーバーサブクラスはこれを異なる方法で実装しており、NextNodeServerはルートモジュールに委譲し、ルートモジュール自身がレンダリングエンジン(App Router ならapp-render.tsx、Pages Router ならrender.tsx)を呼び出します。
開発環境と本番環境の違い
本番環境では、起動シーケンスはずっとシンプルです。next start CLI は HTTP サーバーを作成して initialize() を直接呼び出します——プロセスフォークも、dev バンドラーも、HMR もありません。NextServer ラッパーは DevServer ではなく NextNodeServer を生成し、マニフェストはディスクから一度だけ読み込まれ、動的な更新はありません。
開発環境ではいくつかの追加サブシステムが起動します:
- dev バンドラー(Turbopack または Webpack HMR)がファイル変更を監視する
- まだビルドされていないルートへのアクセス時に
ensurePage()がオンデマンドコンパイルを起動する - エラーオーバーレイがレンダリングエラーをインターセプトしてブラウザに表示する
- router-server が
next.config.jsの変更を監視し、変更があればフル再起動を実行する
DevServer クラスは重要なメソッドをオーバーライドして開発用の振る舞いを注入しています。たとえば、ページがまだコンパイルされていないために loadComponents() が失敗した場合、DevServer は ensurePage() を呼び出してコンパイルをトリガーし、完了を待ってから再度ロードを試みます。
次回の内容
CLI から最初のレスポンスまでの流れを追い、ルーティングとレンダリングを分離する階層的なアーキテクチャと、開発・本番両環境を支える抽象サーバー階層を理解しました。次回は、App Router ページにおける renderToResponse() の内部——React Server Components、ストリーミング、Partial Prerendering を統括する 7,350 行の app-render.tsx ——に深く踏み込んでいきます。