TypeScript+webpack+Hypernova on RailsでSSRするときの設定ファイル

Roppongi.rb #3 で「RailsエンジニアがReactを始めてSSRとReduxを導入するまで」という発表を行いました - Bit Journey's Tech Blog」あたりの話のなかで触れている、KibelaにおけるJSのビルド環境に関係する設定ファイルなどを公開します。

現在もこのときと基本的な構成は変わっておらず、すでに数ヶ月安定して運用できています。

環境

  • Rails v5.1
  • TypeScript v2.5
  • webpack v3.6
  • Hypernova*1
  • webpackerは不使用で、webpack-rails*2を使用

ディレクトリ構成

  • client/ - フロントエンド用TSコード(.ts, .tsx,.json)
  • spec/javascripts/* - テスト用TSコード

なおプロダクション用フロントエンドコードは 100% TypeScript です。そのうち、SSRで使うファイルはUniversal JavaScript (a.k.a. Isomorphic JavaScript) になっています。

TypeScript 設定ファイル

TypeScriptの実行環境は3つあるため、設定ファイルも3種類+基本設定の4ファイルあります。

  • tsconfig.base.json: 基本設定
  • tscon.json: SSR用。ts-node*3で実行
    • vscodeを触るときはこれが参照される
  • tsconfig.webpack.json: webpackでコンパイル&バンドルしてbrowserで実行
  • spec/javascripts/tsconfig.json: テスト用。ts-nodeで実行

それぞれ見ていきましょう。なお詳細は tsconfig.json · TypeScript をどうぞ。

tsconfig.base.json

{
    "compilerOptions": {
        "allowSyntheticDefaultImports": true,
        "alwaysStrict": true,
        "forceConsistentCasingInFileNames": true,
        "jsx": "react",
        "lib": [
            "es2017",
            "dom"
        ],
        "module": "commonjs",
        "moduleResolution": "node",
        "noEmitOnError": true,
        "noFallthroughCasesInSwitch": true,
        "noImplicitAny": false,
        "noImplicitThis": true,
        "noImplicitReturns": true,
        "noUnusedParameters": false,
        "noUnusedLocals": false,
        "outDir": "./build/",
        "pretty": true,
        "removeComments": false,
        "sourceMap": true,
        "strictNullChecks": false,
        "target": "es5",
        "traceResolution": false
    }
}

注意点としては、 "removeComments": false です。これがないと後述のwebpack code splittingの制御ができません。

その他の3ファイルは差分だけです。

tsconfig.json

SSR用であるとともに、vscodeに読み込ませるための設定ファイルです。

{
    "extends": "./tsconfig.base",
    "include": [
        "./client/**/*.*",
        "./hypernova.ts"
    ]
}

tsconfig.webpack.json

これだけ "module": "esnext" になっています。ES modules構文をwebpackに処理させてcode splittingを制御するためです。

{
    "extends": "./tsconfig.base",
    "compilerOptions":{
        "module": "esnext"
    },
    "include": [
        "./client/**/*.*"
    ]
}

spec/javascripts/tsconfig.json

特に変わったことはありません。ts-nodeで実行されます。

{
    "extends": "../../tsconfig.base",
    "compilerOptions": {
        "baseUrl": "../../client",
        "outDir": "../../build/",
        "paths": {
            "*": [
                "*"
            ]
        }
    }
}

webpack.config.js

webpackerではなくwebpack-railsなので、普通にwebpack.config.jsを書いてます。

ウェブアプリ全体でただひとつのエントリポイント bundle.ts を参照し、不要なコードをwebppackのcode splitting*4で分割する構成です。

バッとはってしまうとこんな感じです。

const webpack = require('webpack');
const StatsPlugin = require('stats-webpack-plugin');
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');
const typescriptLoader = require('awesome-typescript-loader');

// Load .env and .env.${environment}(e.g. .env.development)
// priority: .env.${environment} > .env
dotenv.config();
dotenv.config({ path: `./.env.${process.env.NODE_ENV}` });

// must match config.webpack.dev_server.port (default to 3808)
const devServerPort = 3808;

const production = ['production', 'staging',].includes(process.env.NODE_ENV);

const publicDir = "assets";

const publicPath = `${process.env.ASSET_HOST}/${publicDir}/`;

const config = {
  entry: {
    'bundle': [
      "core-js/shim",
      'dom4',
      'blueimp-canvas-to-blob',
      './client/bundle.ts',
    ],
  },

  output: {
    path: `${__dirname}/../public/${publicDir}`,
    publicPath,

    filename: production ? '[name]-[chunkhash].js' : '[name].js',
    chunkFilename: production ? '[name]-[chunkhash].chunk.js' : '[name].chunk.js',
  },

  resolve: {
    modules: [
      `${__dirname}/../client`,
      `node_modules`,
    ],
    extensions: [
      '.js',
      '.jsx',
      '.ts',
      '.tsx',
    ],
  },

  node: {
    __filename: true,
    __dirname: true,
  },

  plugins: [
    // must match config.webpack.manifest_filename
    new StatsPlugin('manifest.json', {
      // We only need assetsByChunkName
      chunkModules: false,
      source: false,
      chunks: false,
      modules: false,
      assets: true
    }),
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: JSON.stringify(process.env.NODE_ENV),
      },
    }),
    new typescriptLoader.CheckerPlugin(),
  ],
  module: {
    rules: [
      {
        test: /\.(?:ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "awesome-typescript-loader",
          options: {
            configFileName: 'tsconfig.webpack.json',
            useBabel: false,
            silent: true,
            useCache: true,
          }
        },
      },
      {
        test: /\.css$/,
        use: [
          "style-loader",
          "css-loader",
        ],
      },
    ]
  },
};

if (production) {
  config.plugins.push(
    new webpack.optimize.UglifyJsPlugin({
      sourceMap: true,
      mangle: true,
      compress: {
        warnings: true,
        drop_console: true,
      },
    }),
    new webpack.optimize.ModuleConcatenationPlugin(),
    new class CleanupAssets {
      apply(compiler) {
        compiler.plugin('done', (stats) => {
          const statsJson = stats.toJson();
          const assets = Array.prototype.concat.apply([], statsJson.chunks.map((chunk) => chunk.files));
          const assetPatterns = Array.prototype.concat.apply([], statsJson.chunks.map((chunk) => {
            return chunk.files.map((file) => {
              const pattern = file.replace(chunk.hash, '[a-fA-F0-9]+');
              return new RegExp(`^${pattern}\$`);
            });
          }));
          const files = fs.readdirSync(`${__dirname}/../public/${publicDir}`);
          files.filter((file) => assetPatterns.some((pattern) => pattern.test(file)))
            .filter((file) => !assets.includes(path.basename(file)))
            .forEach((assetToRemove) => {
              console.log("Removing %s", assetToRemove);
              fs.unlinkSync(`${__dirname}/../public/${publicDir}/${assetToRemove}`);
            });
          process.exit();
        });
      }
    }
  );
  config.devtool = 'source-map';
} else {
  config.devServer = {
    port: devServerPort,
    headers: { 'Access-Control-Allow-Origin': '*' },
    stats: 'minimal',
    disableHostCheck: true,
  };
  config.output.publicPath = `//localhost:${devServerPort}/${publicDir}/`;
  config.devtool = 'cheap-source-map';
}

module.exports = config;

CleanupAssetsが独自ですね。これは本番サーバの public/assets にゴミがたまるので掃除するコードです。blue-green deploymentするなら不要なはずなのでいずれ消すことになるでしょう。

見ての通りbabelは使っておらず、polyfillとしては core-js/shim を直接バンドルして使っています。

またcode splittingを有効にしているのですが、webpackはdynamic importに対するマジックコメント*5でcode splittingの挙動を制御できます。

たとえば、以下のマジックコメントでchunk name (分割したコードのファイル名)を変えられます。

const foo = await import(/* webpackChunkName: "foo" */ 'foo');

また、webpackMode: "eager" によりdynamic importを使いながらcode splittingを抑制することもできます。

const foo = await import(/* webpackMode: "eager" */ 'foo');

Hypernova

SSRのためのnodejs server + client codeです。いまのところ事前コンパイルはしておらず、ts-nodeでtsファイルを自動コンパイルしています。

Hypernova用の実行コードもwebpackなどで事前にコンパイルしおくほうがブラウザ環境に寄せられるのでより良いのですが、まだそれはしていません。

なお、Hypernova serverはpm2*6でプロセス管理をしています。

所感

  • TypeScript設定ファイルはパスやモジュールの解決まわりでかなりハマるので厄介だった
    • 単一ファイルでブラウザとnodejsをサポートするのは不可能だとわかったのでファイルを分けることにした
  • SSRは慣れてくるとそれほど問題なくUniversal JSなコードを書けるようになる
  • Hypernovaはnodejs serverなのでnodejs serverの知見をそのまま使える
    • SSRのときのエラーをブラウザに表示してくれる便利機能もあるのでデバッグはわりとしやすい
    • ただしhypernovaの構造上Rails側でfragment cacheできなくなるというデメリットがある
  • webpacker (v3.0) はちょっとみたところ重厚すぎてこのプロジェクトには合わなかった
    • すでに完成されたwebpack.config.jsがあるのでこれを再利用できるツールがよい
    • しかしwebpack-rails はメンテされないと明言されているのでいずれ何かに移行しなければならないのでどうしたものかというところ