Next.js のサーバが起動するまでを追う (v11.1.2)
v11.1.2 を読んでいく
前提
Next.js でサーバを起動する方法は大きく3つ(まだあるっけ?)
-
next start
で本番サーバ起動 -
next dev
で開発サーバ起動 - Custom server を作って起動 https://nextjs.org/docs/advanced-features/custom-server
これらが起動するサーバはすべて next/server/next
で定義されている NextServer
class に行き着く。
next start
- 呼び出しているところ
- 実体
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/cli/next-start.ts
- 引数・オプションの解釈だけやって、
startServer(...).then(async (app) => app.prepare())
を呼ぶ-
dir
(第1引数, default:.
) -
port
(--port
, default:process.env.PORT || 3000
) -
host
(--host
, default:0.0.0.0
),
-
-
startServer
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/lib/start-server.ts
-
next/server/next
が export してる関数を呼ぶ(w/customServer: false
) -
http.createServer
にgetRequestHandler()
を渡して、listen してapp
返す
next dev
-
next/cli/next-start
と同じディレクトリに実体がある- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/cli/next-dev.ts
-
startServer
を呼ぶところまでは同じで、その後が違う- HTTP server 作成 ~ ハンドラ登録 ~ 起動 のところが
startDevelopmentServer
に置き換わっている
- HTTP server 作成 ~ ハンドラ登録 ~ 起動 のところが
Custom server
- もっともシンプルな例:
import next from "next";
const app = next({ dev: process.env.NODE_ENV !== 'production' });
app.prepare().then(() => { /* HTTP server つくってハンドラ登録して起動して待ち受け */ });
-
import next from "next";
?-
cat packages/next/package.json | jq .main
=>./dist/server/next.js
- →
next start
でstartServer
内で呼ばれているものと同じ
-
NextServer
import next from "next"
const app = next({ dev: process.env.NODE_ENV !== "production" }); // これ
主要 API は3つ
- コンストラクタ
getRequestHandler
prepare
内部的にはいずれも getServer()
が返すオブジェクトに移譲されている
2回目以降は serverPromise
にメモ化されたインスタンスを使い回す。
setTimeout(getServerImpl, 10) this.serverPromise = this.loadConfig().then(async (conf) => { this.server = await this.createServer({ ...this.options, conf, }) if (this.preparedAssetPrefix) { this.server.setAssetPrefix(this.preparedAssetPrefix) } return this.server })
とりあえず以下3つを見ていけば良さそう
getServerImpl
this.loadConfig
this.createServer
setTimeout(getServerImpl, 10)
は単純で、ServerImpl
というファイルローカルな変数を初期化している
// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L21-L27
let ServerImpl: typeof Server
const getServerImpl = async () => {
if (ServerImpl === undefined)
ServerImpl = (await import('./next-server')).default
return ServerImpl
}
setTimeout
で呼んで返り値を捨てているのは、loadConfig
と並行に実行することで起動を早くするためっぽい
Promise.all
じゃないのは、dev: true
時は不要になるからかな(しらんけど)
loadConfig
は面白そうなので独立したスレッドを作る
→ https://zenn.dev/link/comments/f39060abee2b09
createServer
は dev
かどうかで変わる
dev なら DevServer
インスタンスを返し、そうじゃなければ getServerImpl
で読み込んだ Server
を返す
DevServer
は Server
を extend している
それぞれ prepare()
と getRequestHandler()
をみてみる
prepare()
-
Server
はprepare()
では何もしない(backwards compat らしい) -
DevServer
はいろいろやってる- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/dev/next-dev-server.ts#L287-L344
-
verifyTypeScriptSetup
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/lib/verifyTypeScriptSetup.ts#L24
- TypeScript のバージョンとかの確認,
tsconfig.json
よみこみ, 型チェック
-
loadCustomRoutes
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/lib/load-custom-routes.ts#L653-L732
- headers, rewrites, redirects の読み込み
- router 初期化
- hotreloader 初期化
- ファイル監視スタート
- telemetry 記録
- unhandleRejection や uncaughtException をつかまえる
getRequestHandler()
-
prepare()
とは逆で、DevServer
は実装がない(Server
のものが呼ばれる) -
Server
はthis.handleRequest.bind(this)
を返してるだけ -
handleRequest
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next-server.ts#L309-L497
- これが
IncomingRequest
うけて頑張る実装
-
Server
はコンストラクタでRouter
を初期化している -
DevServer
はprepare()
でRouter
を再初期化している- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/dev/next-dev-server.ts#L307
-
コンストラクタで
super
してるので、あくまで再初期化 - ただ、
customRotues
があるときだけらしい- pages は reloader が見てくれるので、config 側の更新だけは自分で見ないといけないということか?
this.loadConfig
NextServer
のなんらかのメソッドを叩くと呼ばれる。
実体は next/server/config
にある loadConfig
やってることはだいたい予想通りで next.config.js
を require してるだけなんだけど、その手前にいくつかポイントがある
`loadConfig` の前半を抜粋
// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/config.ts#L451-L541
export default async function loadConfig(
phase: string,
dir: string,
customConfig?: object | null
): Promise<NextConfigComplete> {
await loadEnvConfig(dir, phase === PHASE_DEVELOPMENT_SERVER, Log)
await loadWebpackHook(phase, dir)
if (customConfig) {
return assignDefaults({
configOrigin: 'server',
...customConfig,
}) as NextConfigComplete
}
const path = await findUp(CONFIG_FILE, { cwd: dir })
// If config file was found
if (path?.length) {
let userConfigModule: any
- 最初に
loadEnvConfig
を呼ぶ-
.env.$NODE_ENV.local
,.env.$NODE_ENV
,.env
を読み出し展開する - ので、
next.config.js
では.env
が展開されていることは前提としていい(ほんまか?)
-
-
loadWebpackHook
は webpack の初期化-
NODE_ENV
によらずjest-worker
なるモジュールが読み込まれていてギョッとするが、これは重いタスクを fork したプロセスで並列実行するためのものらしい- https://www.npmjs.com/package/jest-worker
- 名前に jest って入ってるのは testing framework の Jest が内部で使ってて切り出されたから とかかな?
- なんだかんだで最終的に Next.js にバンドルされた Webpack 4 or 5 とその関連モジュールが読みこまれる
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/config-utils-worker.ts#L16-L20
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/compiled/webpack/webpack.js
-
https://github.com/vercel/next.js/blob/v11.1.2/packages/next/build/webpack/require-hook.ts
-
require("module")._resolveFilename
を拡張している- require 呼び出しをフックして、特定モジュールの path を書き換えているのかな?
-
-
-
customConfig
が渡されていた場合、assignDefault
をしてそのまま return-
configOrigin
ってやつは config がどこから来たのかを記録してるのかな?
-
-
findUp
は find-up というモジュールっぽい(これも Next.js にバンドルされている)- 名前のままで、
cwd
からさかのぼっていって引数に渡されたファイルを探している CONFIG_FILE
は"next.config.js"
- 名前のままで、
-
findUp
が返したファイルパスをrequire
し、なんだかんだして返す-
normalizeConfig
: config が object じゃなく function だったら、phase と defaultConfig わたす -
assignDefault
: validation したり defaultConfig をマージしたり - なので、実は
next.config.js
を関数で書くときに{ ...defaultConfig }
はしなくていいのかも(ほんまか?)
-
-
findUp
が null, undefined, 空文字列を返した(next.config.js
が見つからない)場合-
next.config.{jsx,ts,tsx,json}
を探し、見つかった場合は「サポートしてへんで!」ってエラーにする - それもなければ諦めて
defaultConfig
を返す
-
いくつか重要な挙動があった
next({ /* ... */ })
にconf
を渡した場合、next.config.js
は読まれない-
next.config.js
以外の設定ファイルは許可されていない-
.ts
もだめだし、next.config/index.js
でも多分ダメ
-
とくに前者の挙動は割と罠っぽい
webpack とか、そういう devDependencies
的な外部モジュールが dependencies
に含まれずバンドルされてるのはおもしろポイント
外部モジュールの不意の breaking や相性問題とかに振り回されないようにするためかな?
startDevelopmentServer
next dev
コマンドからの起動シーケンスでのみ実行される関数
next({ dev: true })
でも通らない
- unistore の
setState
を呼んでいるだけ- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/build/output/index.ts#L8-L10
- https://github.com/vercel/next.js/blob/v11.1.2/packages/next/build/output/store.ts#L18-L22
- unistore は React や Preact でつかえる小さな state container らしい
store を subscribe して、起動シーケンスのログ出力につかっているだけ?
Log
custom server にして自前のインフラに出すときとか、stdout / stderror 制御したくなる場合もあるかも(ないかも)
- dev / prod によらず、
next/build/output/log
を使っていそう - 実体は
chalk
で prefix つけたconsole
はい。
graceful shutdown
してるのかな?
next start
は本番で使ったらダメ って言ってる記事もあるので、ちょっと気になる
参照した記事では「Express を使用していない(http
は実装が低レベルすぎる)からダメ」と書いているが、http
使ってても諸々ちゃんとやってくれていればそれでいいので、使ったらダメな理由としては弱すぎる。
v10.0.4 で導入されたよ!と主張している記事
でもこの記事で参照されているコードは SIGTERM
されたらすぐ process.exit(0)
してるだけにみえる。
要するに終了コードを正常に差し替えているだけ。
Graceful shutdown は「アクティブなコネクションが残っている場合はそれを捌き切ってから終了する」くらいのイメージだったので、自分の認識とは違う
Go とかはそんなかんじ
雑に grep したかんじ、ライブラリ内でシグナル監視してる実装はない(まあこれは普通か)
next
コマンド中にはある
`rg process.on`
:) % rg process.on
examples/with-electron-typescript/electron-src/preload.ts
15:process.once('loaded', () => {
packages/next/compiled/amphtml-validator/index.js
1:module.exports=(()=>{var e={306:(e,t,n)=>{"use strict";const r=n(584);const o=n(747);const i=n(605);const s=n(211);const u=n(622);const a=n(654);const [... 1 more match]
packages/next/compiled/conf/index.js
1:module.exports=(()=>{var f={601:f=>{"use strict";f.exports=JSON.parse('{"$schema":"http://json-schema.org/draft-07/schema#","$id":"https://raw.githubu [... 1 more match]
packages/next/compiled/@vercel/nft/index.js
1:module.exports=(()=>{var __webpack_modules__={3380:e=>{"use strict";e.exports=JSON.parse('{"0.1.14":{"node_abi":null,"v8":"1.3"},"0.1.15":{"node_abi": [... 2 more matches]
packages/next/compiled/webpack/index.js
56:process.on('message', function (data) {
packages/next/compiled/webpack/bundle4.js
48720: process.on('exit', function() {
97260: this.processContext(parser, expr, param);
97304: this.processContext(parser, expr, param);
97331: processContext(parser, expr, param) {
97985: this.processContext(parser, expr, p);
98026: this.processContext(parser, expr, p);
98060: processContext(parser, expr, param) {
98635: const processContext = (expr, param) => {
98718: processContext(expr, param);
103386: processContext(expr, option, weak);
103396: processContext(expr, param, weak);
103414: const processContext = (expr, param, weak) => {
packages/next/compiled/webpack/bundle5.js
29823: process.on('exit', function() {
40951: const processConnection = (connection, stateInfo) => {
40971: processConnection(connection, state === true ? "T" : "O");
40992: processConnection(connection, stateInfo);
59134: const processContextHashSnapshot = (path, hash) => {
59166: processContextHashSnapshot(path, hash);
59174: processContextHashSnapshot(path, tsh);
59183: processContextHashSnapshot(path, tsh.hash);
59190: processContextHashSnapshot(path, tsh.hash);
78525: const processConnectQueue = () => {
78927: processConnectQueue();
86690: this.processContext(parser, expr, param);
86730: this.processContext(parser, expr, param);
86764: processContext(parser, expr, param) {
87476: this.processContext(parser, expr, p);
87513: this.processContext(parser, expr, p);
87550: processContext(parser, expr, param) {
132209: // Find the maximum group and process only this one
139897: // Process one dependency
packages/next/compiled/ora/index.js
1:module.exports=(()=>{var e={250:(e,t,r)=>{var s=r(820);e.exports=function(e,t){e=e||{};Object.keys(t).forEach(function(r){if(typeof e[r]==="undefined" [... 1 more match]
packages/next/server/dev/next-dev-server.ts
336: process.on('unhandledRejection', (reason) => {
341: process.on('uncaughtException', (err) => {
packages/next/bin/next.ts
97:process.on('SIGTERM', () => process.exit(0))
98:process.on('SIGINT', () => process.exit(0))
bench/capture-trace.js
54: process.on('SIGINT', () => {
これは前述の記事にあったとおりで、終了コードを変えてるだけで HTTP の接続には関知しない
// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/bin/next.ts#L96-L98
// Make sure commands gracefully respect termination signals (e.g. from Docker)
process.on('SIGTERM', () => process.exit(0))
process.on('SIGINT', () => process.exit(0))
next server
の HTTP server の実体はさっき読んだとおりで、単に http.createServer
で作ったやつ
// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/lib/start-server.ts#L13-L19
const srv = http.createServer(app.getRequestHandler())
await new Promise<void>((resolve, reject) => {
// This code catches EADDRINUSE error if the port is already in use
srv.on('error', reject)
srv.on('listening', () => resolve())
srv.listen(port, hostname)
})
雑に実験。以下のコマンドでサーバを起動し、curl を投げて返ってくる前にサーバを落とす。
node -e 'require("http").createServer((_, resp) => { setTimeout(() => { resp.write("ok"); resp.end() }, 5000) }).listen(3000)'
ダメです。
:) % curl http://localhost:3000
curl: (52) Empty reply from server
なので、「Next.js サーバをセルフホストするときは next start
を使ったらダメ」という主張自体は真である。
じゃあ自分で Express 使えば大丈夫!かというとそんなことはなさそうで、Graceful shutdown とかは自前で実装する必要はある。
そりゃそうか(単なるライブラリにシグナル捕まえられても困りそう)。
一応実験。
node -e 'app=require("express")(); app.get("/", (_, resp) => { setTimeout(() => { resp.send("ok") }, 5000)}); app.listen(3000)'
ダメです。
:) % curl http://localhost:3000
curl: (52) Empty reply from server
next start
の話でいうと、npm start
や yarn start
を本番で使わないほうがいいという問題もある
(じゃあ node_modules/.bin/next start
ならいいのかというと、それはさっき書いたように実装が雑なのでダメ)
graceful shutdow の話は、Blue-Green Deployment だとそこまで問題にならないのか
Rolling deploy を採用してると困る
ちゃんとこの辺読もうって話
cli 周りが分からなかったので、参考になりました!ありがとうございます!
V14 の getRequestHandler 周りの状況を調べたら、
V11 では getRequestHandler() で router.execute の1つの処理でレンダリングを行っているっぽいのが、V14 だと router server と render-server の2つに分けれて、ルーティング(マニフェストを読み込んで、ダイナミックルーティングなどの処理を行う)とレンダリング(コンポーネントをサーバーサイドレンダリングさせて、HTML に変換して、フロントに渡す)を行っているみたいですね。
ルーティングはここら辺で行っていて、
レンダリングは、page router は分かりやすいんですが、app-router は分かりづらいですね。