Open30

Next.js のサーバが起動するまでを追う (v11.1.2)

前提

Next.js でサーバを起動する方法は大きく3つ(まだあるっけ?)

これらが起動するサーバはすべて next/server/next で定義されている NextServer class に行き着く。

next start

next dev

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 startstartServer 内で呼ばれているものと同じ

NextServer

import next from "next"

const app = next({ dev: process.env.NODE_ENV !== "production" });  // これ

https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L29

主要 API は3つ

  • コンストラクタ
  • getRequestHandler
  • prepare

内部的にはいずれも getServer() が返すオブジェクトに移譲されている

https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L130-L140

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 時は不要になるからかな(しらんけど)

https://github.com/vercel/next.js/pull/24129

DevServerServer を extend している
それぞれ prepare()getRequestHandler() をみてみる

prepare()

getRequestHandler()

this.loadConfig

NextServer のなんらかのメソッドを叩くと呼ばれる。

https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L119-L126

実体は 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

いくつか重要な挙動があった

  • next({ /* ... */ })conf を渡した場合、next.config.js は読まれない
  • next.config.js 以外の設定ファイルは許可されていない
    • .ts もだめだし、next.config/index.js でも多分ダメ

とくに前者の挙動は割と罠っぽい

webpack とか、そういう devDependencies 的な外部モジュールが dependencies に含まれずバンドルされてるのはおもしろポイント
外部モジュールの不意の breaking や相性問題とかに振り回されないようにするためかな?

startDevelopmentServer

next dev コマンドからの起動シーケンスでのみ実行される関数
next({ dev: true }) でも通らない

Log

custom server にして自前のインフラに出すときとか、stdout / stderror 制御したくなる場合もあるかも(ないかも)

graceful shutdown

してるのかな?
next start は本番で使ったらダメ って言ってる記事もあるので、ちょっと気になる

https://zenn.dev/umireon/articles/2e6add9aa34dbb

参照した記事では「Express を使用していない(http は実装が低レベルすぎる)からダメ」と書いているが、http 使ってても諸々ちゃんとやってくれていればそれでいいので、使ったらダメな理由としては弱すぎる。

v10.0.4 で導入されたよ!と主張している記事

https://mzqvis6akmakplpmcjx3.hatenablog.com/entry/2021/01/14/103134

でもこの記事で参照されているコードは SIGTERM されたらすぐ process.exit(0) してるだけにみえる。
要するに終了コードを正常に差し替えているだけ。

Graceful shutdown は「アクティブなコネクションが残っている場合はそれを捌き切ってから終了する」くらいのイメージだったので、自分の認識とは違う

Go とかはそんなかんじ

https://pkg.go.dev/net/http#Server.Shutdown

雑に 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 とかは自前で実装する必要はある。
そりゃそうか(単なるライブラリにシグナル捕まえられても困りそう)。

https://expressjs.com/en/advanced/healthcheck-graceful-shutdown.html

一応実験。

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

graceful shutdow の話は、Blue-Green Deployment だとそこまで問題にならないのか
Rolling deploy を採用してると困る

ログインするとコメントできます