Node.js で最小限の Hot Reload サーバーを実現する

2022年にもなって HMR ではなく普通の Hot Reload を実装する機会に恵まれたので、現代的な API でできるだけローコストに実現してみることにしました。

重厚な JavaScript を使って Front-End を表現するわけではないため、実現したい機能はシンプルに以下に通りです。

  • 監視対象となるファイルの内部をリアルタイムで監視し続ける
  • 対象のファイルに変更が入ったとき、特定の処理を実装し、完了したらブラウザをリロードする

今回はファイル監視後に Node.js を利用してのビルド処理が挟まるため、 Node.js で実装します。

Hot Reload 用サーバー

サーバー側の実装は、 Node.js 標準の http と chokidar で実装します。

Zero Dependencies のほうが取り回しは良いものの、 fs.watch はあまり優秀ではないため採用。このパッケージ自体に声がかかるのも、タスクランナーが主流だった頃以来です。

手元で使っているコードは TS ですが、最小限で動かすためにここでは server.js としてのコードを記載します。

// server.js
import chokidar from 'chokidar'
import http from 'http'

const PORT = 10020
const port = ~~(process.env.POLLING_SERVER_PORT || 0) || PORT

let clients = []

async function onUpdate(filepath) {
  // このあたりに実処理を書く
  return
}

http.createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('Access-Control-Request-Method', '*')
  res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET')
  res.setHeader('Access-Control-Allow-Headers', '*')
  if (req.url !== '/poll') {
    res.statusCode = 400
    res.end()
  }
  clients.push(res)
}).listen(port, () => {
  console.log(`Listen on http://localhost:${port}`)
})

chokidar
.watch('./src')
.on('all', async (event, filepath) => {
  await onUpdate(filepath)
  clients.forEach((client) => {
    client.end()
  })
  clients = []
})

処理としては HTTP リクエストがきたら clients に一度格納し、ファイル更新とその postprocess が完了したときにまとめて通信を終了しています。

最近のツールだと ws:// を張って解決することもありますが、ローカル以外で動作しない環境の場合、 Long Polling と同等の実装で十分です。

Hot Reload を受け付けるクライアント

例としては src/index.html に配置されていることが想定されるクライアントのコードは以下のようになります。シンプルにページにアクセスした時点からリクエストを待ち続け、リクエストが正常に完了したら location.reload() でページ自体をリロードします。

res の status が 200 以外のケースやタイムアウト、そもそも fetch に失敗して catch となったケースで再 subscribe しても良いですが、環境やコードの触りかたによっては無限ループを発生させないため、このサンプルでは再 subscribe はしないようにしてあります。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script>
    async function subscribe() {
      fetch('http://localhost:10020/poll')
      .then((res) => {
        if (res.status === 200) {
          location.reload()
        }
      })
      .catch((e) => {
        console.error(e)
      })
    }
    subscribe();
  </script>
</body>
</html>

おわりに

実際にやっている処理としては React Server の renderToString で HTML 化して吐き出すというもので、開発中は JSX を使いながらランタイムでは JavaScript 非依存となるため取り回しやすく重宝していました。

ただこのような形で開発を行っていると、フレームワークやビルドツールの持つ HMR の仕組みに乗れないという欠点があるため、今回は最小限のものとして Hot Reload を見繕うことで解決した次第です。

なんだかんだいろんなシチュエーションで重宝しそうなので、よければぜひ。