webpackを使ってサーバーサイドでもhot reloadがしたい

React.js をはじめとするクライアント側の JavaScript ライブラリ・フレームワークを使った開発環境では、コードを書き換えると何もせずとも画面を自動更新してくれる hot reload 機能が当たり前になっています。

今回はこの便利機能である hot reload をサーバー側にも導入するという内容です。

環境

サーバーサイドフレームワークには Express を使います。その他の主要ライブラリは以下の通り。

package.json
{
  "dependencies": {
    "express": "4.17.1"
  },
  "devDependencies": {
    "@babel/cli": "7.5.5",
    "@babel/core": "7.5.5",
    "@babel/node": "7.5.5",
    "@babel/preset-env": "7.5.5",
    "@babel/preset-typescript": "7.3.3",
    "@types/express": "4.17.1",
    "@types/webpack": "4.39.0",
    "babel-loader": "8.0.6",
    "typescript": "3.5.3",
    "webpack": "4.39.2",
    "webpack-cli": "3.3.7",
    "webpack-dev-middleware": "3.7.0",
    "webpack-node-externals": "1.7.2"
  }
}

server.js

はじめに、サーバーのエントリーポイントとなる server.ts を作ります。npm run dev を実行してサーバーを起動させると、localhost:3000 にアクセスできるようになります。

package.json
"scripts": {
  "dev": "babel-node src/server -x '.js,.ts'",
}
src/server.ts
import http from 'http';
import express from 'express';

const app = express();

const server = http.createServer(app);

server.listen(3000);

server.on('error', console.log);
server.on('listening', () => {
  console.log('Server is listening: 3000');
});

router

つぎにルーティング用の router.ts を作ります。

src/router.ts
import path from 'path';
import * as express from 'express';

const router = express.Router();

router.get('/api', (_, res) =>
  res.json({
    request: true,
    id: 'aaaaa',
    count: 123,
  })
);

router.use('*',
  (
    _req: express.Request,
    res: express.Response,
    _next: express.NextFunction
  ) => {
    res.status(404);
    return res.send('404');
  }
);

export default router;
src/server.ts
import http from 'http';
import express from 'express';
import router from './router';

const app = express();
app.use(router);

const server = http.createServer(app);

server.listen(3000);

server.on('error', console.log);
server.on('listening', () => {
  console.log('Server is listening: 3000');
});

これで localhost:3000/api からレスポンスが帰ってくる API サーバーができました。しかし、このままではコードを書き換えても hot reload してくれません。そこで webpack を使います。

webpack

今回は ts ファイルを node 用にトランスパイルするので、targetexternals などに node 用のオプションを指定します。webpack のエントリーポイントは src/router.ts になります。

webpack.config.js
const { join } = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
  name: 'server',
  target: 'node',
  node: {
    __dirname: false,
    __filename: false,
  },
  entry: {
    'entry.server': join(__dirname, 'src', 'router'),
  },
  output: {
    path: join(__dirname, 'build', 'server'),
    filename: '[name].[hash].js',
    chunkFilename: '[name].[hash].js',
    libraryTarget: 'commonjs2',
  },
  module: {
    rules: [
      {
        test: /\.(js|ts)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  externals: [nodeExternals()],
  plugins: [],
};

Expressにwebpackを組み込む

次に hot reload するための Express 用の middleware を作成します。この middleware は、webpack が実行されるたびに新しいルーティング内容を処理する関数を Express に返します。

つまり、コードを変更するたびに新しい router が実行されるため、サーバーサイドでも hot reload ができるようになります。

src/hot-server-middleware/index.ts
import * as express from 'express';
import { Compiler } from 'webpack';
import { getFilename, requireFromString } from './utils';

const MIDDLEWARE_NAME = 'hot-server-middleware';

interface Options {
  chunkName: string;
}

const options: Options = {
  chunkName: 'entry.server',
};

function hotServerMiddleware(compiler: Compiler) {
  let renderer: Function | undefined;
  let error: any = false;

  compiler.hooks.done.tap(MIDDLEWARE_NAME, stats => {
    error = false;

    if (stats.compilation.errors.length) {
      error = stats.compilation.errors;
      return;
    }

    const filename = getFilename(stats, compiler.outputPath, options.chunkName);
    const buffer = compiler.inputFileSystem.readFileSync(filename);

    try {
      renderer = requireFromString(buffer.toString(), filename).default;
    } catch (requireerror) {
      error = requireerror;
      return;
    }

    if (typeof renderer !== 'function') {
      throw new Error('renderer must export a function.');
    }
  });

  return (
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
  ) => {
    if (error) {
      return next(error);
    }
    if (typeof renderer !== 'function') {
      return next(new Error('renderer is not function.'));
    }
    return renderer(req, res, next);
  };
}

module.exports = hotServerMiddleware;
src/hot-server-middleware/utils.ts
import path from 'path';
import Module from 'module';
import { Stats } from 'webpack';

export const getFilename = (
  stats: Stats,
  outputPath: string,
  chunkName: string
) => {
  const { assetsByChunkName } = stats.toJson();
  const filename = assetsByChunkName && assetsByChunkName[chunkName];

  return path.join(
    outputPath,
    Array.isArray(filename)
      ? filename.find(asset => /\.js$/.test(asset))
      : filename
  );
};

export const requireFromString = (code: string, filename: string) => {
  const parent = module.parent || undefined;
  const m = new Module(filename, parent);
  m.filename = filename;
  // @ts-ignore
  const paths: string[] = Module._nodeModulePaths(path.dirname(filename));
  m.paths = paths;
  // @ts-ignore
  m._compile(code, filename);
  return m.exports;
};

Express に webpack-dev-middlewarehot-server-middleware を組み込みます。

src/server.ts
import http from 'http';
import express from 'express';

const app = express();

if (process.env.NODE_ENV !== 'production') {
  const webpack = require('webpack');
  const webpackDevMiddleware = require('webpack-dev-middleware');
  const hotServerMiddleware = require('./hot-server-middleware');
  const webpackConfig = require('../webpack.config');
  const compiler = webpack(webpackConfig);
  app.use(
    webpackDevMiddleware(compiler, {
      publicPath: webpackConfig.output.publicPath,
      writeToDisk: true,
    })
  );
  app.use(hotServerMiddleware(compiler));
}

const server = http.createServer(app);

server.listen(3000);

server.on('error', console.log);
server.on('listening', () => {
  console.log('Server is listening: 3000');
});

実行

babel.config.js
module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-typescript'],
};

babel の設定を追加して実行してみます。npm run dev でサーバーを起動した状態で router.ts 内のレスポンス部分を変更してみると、瞬時に webpack が実行されます。レスポンスも変更されていることが確認できます。

本番用ビルド

最後に本番用のビルド設定・コマンドを追加します。ビルドコマンドは npm run build、起動コマンドは npm run start です。ビルドの生成ディレクトリは build です。

package.json
"scripts": {
  "dev": "babel-node src/server -x '.js,.ts'",
  "start": "NODE_ENV=production node build/server",
  "prebuild": "rm -rf build",
  "build": "NODE_ENV=production babel ./src/ -D -d build -x '.js,.ts'"
}
src/server.ts
import http from 'http';
import express from 'express';

const app = express();

if (process.env.NODE_ENV !== 'production') {
  const webpack = require('webpack');
  const webpackDevMiddleware = require('webpack-dev-middleware');
  const hotServerMiddleware = require('./hot-server-middleware');
  const webpackConfig = require('../webpack.config');
  const compiler = webpack(webpackConfig);
  app.use(
    webpackDevMiddleware(compiler, {
      publicPath: webpackConfig.output.publicPath,
      writeToDisk: true,
    })
  );
  app.use(hotServerMiddleware(compiler));
} else {
  const router = require('./router').default;
  app.use(router);
}

const server = http.createServer(app);

server.listen(3000);

server.on('error', console.log);
server.on('listening', () => {
  console.log('Server is listening: 3000');
});

おわり

以上がサーバーサイドで hot reload を実現するサンプルコードになります。課題点としては、ルーティングファイルより前のコード、つまり server.ts では hot reload ができません。ただ、server.ts のようなファイルはそもそも更新することが少ないので個人的にはそれほど大きな問題点ではないと思っています。

サーバーサイドで自動更新を行うにはどうすればいいか検索してみると「nodemon 使え!」で終わってる記事くらいしかヒットしません。それはそれでいいのですが、nodemon はサーバーを再起動させるため、コードが増えると起動するまでに時間がかかって結局遅いみたいな現象が発生します。

その点今回の方法は、初回起動は遅いですが更新時にサーバーは再起動しないので変更反映までが早いです。Developer Experience 高めでオススメです。おしまい。