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 を見繕うことで解決した次第です。
なんだかんだいろんなシチュエーションで重宝しそうなので、よければぜひ。