技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

module bundlerの作り方(ECMAScript Modules編)

前回の準備編では、module bundlerがどのように動いているかを説明しました。

blog.hiroppy.me

今回は、dynamic import以外の最低限の実装を入れていきます。

リポジトリ

github.com

変更されたコード一覧はこちら

ECMAScript Modules(ESM)について

さて、多くの人がすでに使っている以下のような構文がESMと呼ばれるものです。

import { version } from 'module';
export const a = 1;

仕様等のドキュメント

tc39.es

whatwg.github.io

exploringjs.com

また、Node.jsでのESMの解決方法はCJSとの互換性を保つために別途異なるのでここでは扱いません。

blog.hiroppy.me

webpackでは現在、Node.jsのscope-packageの対応中なので、もう少しお待ち下さい。

ECMAScript Modules と CommonJS Modulesの違い

ESMの特徴は、事前に静的解析を行います。 そうすると以下のようなメリットが得られます。

  • ランタイムでシンタックスエラーが発生することを避けれる
  • 不必要なコードを消す(dead code elimination)ことが容易に行える
    • CJSのtree shaking対応はwebpackで現在進行中

ESMはトップレベルで宣言しないといけないのはそのような理由があるからです。
また、CJSは同期ですが、dynamic importは非同期です。(require.ensure除く)

出力されるランタイムコード

先に完成したコードを見ていきます。

// entry.js
import { add as addNumber } from './module1.js';

console.log(addNumber(10));

// module1.js
export function add(n) {
  return 10 + n;
}

上記のコードは以下のような出力になります。

((modules) => {
  const usedModules = {};

  function require(moduleId) {
    if (usedModules[moduleId]) {
      return usedModules[moduleId].exports;
    }

    const module = (usedModules[moduleId] = {
      exports: {},
    });

    modules[moduleId](module, module.exports, require);

    return module.exports;
  }

  require.__defineExports = (exports, exporters) => {
    Object.entries(exporters).forEach(([key, value]) => {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: value,
      });
    });
  };

  return require(0);
})({
  0: function (module, exports, require) {
    const __BUNDLER__1 = require(1);

    console.log(__BUNDLER__1['add'](10));
  },
  1: function (module, exports, require) {
    function add(n) {
      return 10 + n;
    }

    require.__defineExports(exports, {
      add: () => add,
    });
  },
});

今回はCJSとの互換をあまり考えないため、__esModuleexportsアサインはしません。

ほかのパターンを見る場合はこちら

CJSとESMの出力の違い

ESMも最終的にはCJSに合わせた状態(require)になりますが、大きな違いが2点あります。

ESMではランタイムコード生成時に既に実行先が確定される

先程、話したとおりESMでは静的解析を行うことが前提となるため実行前にすべて確定されます。
このコードをCJSで書くと以下のような出力になります。

// CJS
})({
  0: function (module, exports, require) {
    const { add: addNumber } = require(1);

    console.log(addNumber(10));
  },
  1: function (module, exports, require) {
    function add(n) {
      return 10 + n;
    }

    module.exports = {
      add,
    };
  },
});

CJSの場合、10に呼び出されてその時にusedModules[1].exportsaddが登録され、0で使われます。 しかし、0からすると本当に1addがあるのかわからないため、実行時に落ちる可能性があります。

ESMは事前に実行するものを予約したコードに変換することによりこの問題を防ぎます。

// ESM
})({
  0: function (module, exports, require) {
    const __BUNDLER__1 = require(1);

    console.log(__BUNDLER__1['add'](10));
  },
  1: function (module, exports, require) {
    function add(n) {
      return 10 + n;
    }

    require.__defineExports(exports, {
      add: () => add,
    });
  },
});

__BUNDLER__1['add'](10) のように必ず実行できる形でコードが吐かれます。
もう少ししっかり書くなら、(0, __BUNDLER__1['add'])(10);と変換したほうが良いです。 こうしないとthisのスコープを担保できないからです。更に、.addに変えるとコード量も減ります。(たしかwebpack@3でそっちに移行した記憶)

つまり、呼び出す側が既に呼び出される側の内部を把握している状態となり実行時にエラーを起こすのを防げるということです。

属性の付与が必要となる

新しくrequire__defineExportsを追加しました。
これは、exportsされるものに必ずenumerableを付与しなければいけないためです。

developer.mozilla.org

IIFE内で共通で使うために以下を定義します。

require.__defineExports = (exports, exporters) => {
  Object.entries(exporters).forEach(([key, value]) => {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: value,
    });
  });
};

そして、モジュールが初めて呼び出された時に以下のrequire.__defineExportsを実行し、usedModules[1].exportsObject.defineProperty経由でプロパティを追加します。

1: function (module, exports, require) {
  function add(n) {
    return 10 + n;
  }

  require.__defineExports(exports, {
    add: () => add, // このオブジェクトにこのファイル内のexportされるものが追加されている
  });

コードを書き換える

ASTを弄りたい人はastexplorerを使うと便利です。

astexplorer.net

CJSではコードの書き換えはrequire('module') ->require(1)のようにmoduleIdのみの書き換えでしたがESMでは呼び出し元等を編集する必要があります。

ESMもCJSとモジュールのマップ作成の処理は共通なので、コードの走査処理だけがESM時に新しく追加されます。コード箇所

  • CJS: modulesのリストを作る -> requireの中身をmoduleIdに変更する
  • ESM: modulesのリストを作る -> ソースコードをトラバースする -> requireの中身をmoduleIdに変更する
    • 二段階目でrequireに変換し、CJSと同じ形になるため三段階目はCJSと共通で動く

それでは、babelを使いASTを走査し以下のことを達成していきます。

  • importrequireへ変換
    • (e.g. import a from 'module' -> const a = require('module'))
  • 外部モジュールから使っている変数、関数等をすべて置換する
    • (e.g. a(1); -> __BUNDLER__1["a"](1))
  • exportをすべてマッピングし、不必要なシンタックスを消す
    • (e.g. export const a = 1 -> const a = 1)

importを変更する

コード箇所

親はImportDeclarationとなり、typeはImportDefaultSpecifier, ImportNamespaceSpecifier, ImportSpecifierとなります。

// ImportDefaultSpecifier
import a from 'module';
// ImportNamespaceSpecifier
import { a } from 'module';
import * as a from 'module';
// ImportSpecifier
import { a as b } from 'module';
import { default as a } from 'module'; // CJSとESMの互換ブリッジ

ゴールはrequireに変更し、格納先の変数名にId込の名前を付与することです。

最初にモジュールのマッピングをしIdを発行しているのでそれを変数名へ紐付けていきます。

import { a } from 'module' -> const __BUNDLE__1 = require('module')

importは、どのtypeでもconst a = require('b')となるためすべて共通化できます。

exportを変更する

exportはいろいろなケース(e.g. アグリゲート)があるので、最小限の実装にしています。

コード箇所

親はExportDeclarationとなり、typeはExportDefaultDeclarationExportNamedDeclarationとなります。

// ExportDefaultDeclaration
  // FunctionDeclaration
  export default function a() {}
  // Identifier
  const a = 1;
  export default a;
  // ArrowFunctionExpression 本当はclassも追加しないとダメ
  export default () => {}
// ExportNamedDeclaration
  export const a = 1;
  // 未実装
  export { a, b };
  export a from 'module';

ゴールは、exportされるもののマッピングで、名前と接続先を把握し以下のように展開します。

  function add(n) { return 10 + n; }

  require.__defineExports(exports, {
 // 名前       接続先
    add: () => add,
  });

名前を付与する

最初にexportされるものの名前を取得しなければなりませんが、状況によって取り方が異なります。

// export default function a()
node.declaration.name
// function a() {}
// export default a;
node.declaration.id && node.declaration.id.name
// export const a = 1
node.declaration.declarations && node.declaration.declarations[0].id.name

これで名前が取れないときは、export default () => {} やクラスの可能性を考慮します。
この場合は、名前をこちらが付けてあげて接続先を作ります。(e.g. key: default, name: __default__)

export default () => {}; -> const __default = () => {}; と書き換えて名前を付けます。

不要なコードを消す

Object.defineProperty(exports, key)で値を展開していくため、コード内に不要なコードがあります。

  • export default function a() {} -> function a() {}
  • export default a -> この行事自体が不要
  • export const a = 1 -> const a = 1

exportに付随したコードはランタイムでは必要なくなります。

importされたものをコード内で紐付け置換する

コード箇所

コード内のimportしたものを使っている箇所をすべて書き換えます。
ReferencedIdentifierに入ってきて、かつ親のtypeがImportDeclarationがターゲットです。

ゴールは、importされたモジュールが呼び出されている場所を__BUNDLER__{id}[function/variable name]に変えていくことです。

// before
import { foo as bar } from 'module';
console.log(bar(10));
// after
const __BUNDLER__1 = require('module');
console.log(__BUNDLER_1['foo'](10));

最初に、scope hoistingを考えないといけないため以下の処理を行いスコープを固定します。
これを行うことによりimportされたものとコード内で使われているものが正しく紐付けられます。

const localName = path.node.name;
const localBinding = path.scope.getBinding(localName);
const { parent } = localBinding.path; // 親を確定させる

importは複数連続({a, b, ...})して入っているのでループし、確認していきます。

parent.specifiers.forEach(({ type, local, imported }) => {});

このループの中でそれぞれのexportされたものとコード内で使用されるものを置換してきます。

それのlocalはコード内の情報を示し、importedはコード外の情報を意味します。
つまり、a as bの場合、imported as local となりimportedは元の情報を辿る上で重要な役割を果たします。

ImportNamespaceSpecifier: import * as foo from 'module'

一番めんどくさいです。
というのも、defaultを省略できるかどうかはプラットフォームに依存しているため互換性を考えないといけないからです。(Node.js, Babel, typescript, etc..)
例えば、出力先がmodule.exports = require('./foo') とかが代表的な例です。

ここでの最終的な展開式は以下のようになります。

const __BUNDLER_1 = require(1);
console.log(__BUNDLER_1['default']); // import先のdefault
console.log(__BUNDLER_1['func']()); // import先のnamed exportされたfunc
console.log(__BUNDLER_1);
/**
 * default: xxxx
 * func: yyyy
 */

しかし、これは互換性を崩しているので実際はよくなくて、以下のほうが安全です。

const __BUNDLER_1 = require(1);
console.log(__BUNDLER_1['default']['default']); // import先のdefault
console.log(__BUNDLER_1['default']['func']()); // import先のnamed exportされたfunc
console.log(__BUNDLER_1);
/**
 * default: {
 *   default: xxxx,
 *   func: yyyy
 * },
 * func: yyyy
 */

// esModuleの場合はgetDefaultを使うを判別する関数をIIFEに作成する
const getDefaultExport = function (module) {
  const getter = module && module.__esModule ?
    function getDefault() { return module['default']; } :
    function getModuleExports() { return module; };
  Object.defineProperty(getter, 'esm', { enumerable: true, get: getter });
  return getter;
};

このサンプルではdefaultを1階層挟まずにやっているためObjectのkeyが直で名前となります。

ImportDefaultSpecifier: import foo from 'module'

defaultは必ず一つしか無いため、nameはdefaultとなります。

ImportSpecifier: import { foo } from 'module'

普通のnamed importであれば、nameはimport先と同じです。
問題は、リネームされている場合です。この場合は、imported.nameを見る必要があります。
そして、ローカルのリネームされたものは全部置き換えます。

// import { foo as bar } from 'module';
const __BUNDLER__1 = require('module');
console.log(__BUNDLER_1['foo'](10)); // <--- barはもういらないのでfooに戻してあげる

置換を行う

それぞれの状態でもnameを取れたので最後に置換を行います。

const assignment = t.identifier(`${prefix}${moduleId}${name ? `[${JSON.stringify(name)}]` : ''}`);
// ImportNamespaceSpecifierは今回defaultを省いたので以下
// replace `['foo'].foo` with `['foo']`
path.parentPath.replaceWith(assignment);
// その他
path.replaceWith(assignment);

ここでのnodeの区間は以下の箇所です。

// parent node:  |             ここ               |
// current node: |     この区間      | ここはとなり |
                                 foo()
                  __BUNDLER_1['foo']()

なぜImportNamespaceSpecifierではparentPath.replaceWithを使っているかというと()部分も修正したかったからです。

これでコード置換が行えたので、最初に出したランタイムコードが作成されます。

さいごに

以上で、module bundlerでESMの対応をする方法の紹介は終わりです。
すべてのコードはこのPRを見るとわかります。

github.com

本当はここでtree shakingもやろうと思ったんだけど、予想以上に量が多くなってしまったので次回やります。
もし何か聞きたいことあったら、Twitterまで。

module bundlerの作り方(準備編)

今回は中身がどう動いているかを解説したいと思います。
最初のこの記事では、最低限の実装を説明していくことにします。

webpackのアルゴリズムの仕組みはこちらを読んでください。

blog.hiroppy.me

必要なステップ

必要なステップは以下の3つです。

  • エントリーポイントからのすべてのモジュールを走査し、requireを解決後にユニークidを付与していく
  • コード内のモジュールパス(requireの引数(e.g. ./module.js))をidへ置換する
  • runtime のコードテンプレートの作成
    • IIFE(即時関数)箇所とそれに付随する引数のmodule群

この実装されあれば、動くコードはできます。(2つめはoptionalでもいいけど後からつらくなる)

モジュール解決

今回は説明しやすいように関数を2つに分けています。

  • すべてのモジュールの把握とID作成
  • コード内のrequire部分をIDを置換

1. 使われるモジュールをすべて列挙し、ユニークIDを添付させる

ここでのゴールは、モジュールのコードをすべてASTにしそれぞれのモジュールにユニークなIDを付与していくことです。

今回は、webpackでも使うJSのパーサーであるacornを使っても良いのですが、codegenがほしいのでbabelを使って行います。(もちろん、acornescodegenを使っても良い)

@babel/parserでコードをASTに落とし、@babel/traverse でトラバースをし、@babel/generatorでASTからコードを生成します。

以下の処理を再帰させることにより、使用されるモジュールを列挙します。

ファイル名を取得する

require の declarations type は CallExpressioncalleeのtypeは Identifier となります。

const parser = require('@babel/parser');
const { default: traverse } = require('@babel/traverse');
const { default: generate } = require('@babel/generator');

const basePath = dirname(entryPath);
const ast = parser.parse(await promises.readFile(entryPath, 'utf8'));

 traverse(ast, {
  CallExpression({ node: { callee, arguments: args } }) {
    if (callee.type === 'Identifier' && callee.name === 'require') {
      const filePath = getScriptFilePath(basePath, args[0].value);
    }
  },
});

つまり、このように構文解析を行えば、呼び出されるモジュールファイルがわかります。
そして、CJSでは拡張子が省略可能であり、index.js.で省略可能なので、以下の処理が必要です。

function getFilename(filename) {
  // index.js === .
  if (filename === '.') {
    return 'index.js';
  }

  // omit .js
  if (extname(filename) === '') {
    return `${filename}.js`;
  }

  return filename;
}

node_modulesのファイルを読み込む

注意点は以下の通りです。

  • node_modulesもxxx/yyyの形式でも動く
  • mainフィールドがない場合は、'./index.js'を参照する
  • node_modules内のモジュールの子供は./等でアクセスするため少し処理が複雑になる
    • 再帰処理を行う時にxxxが来たらベースのパスをリセットしrootの値にする
    • そうでない場合は前回のベースのパスを引き継ぐために引数に渡す

また、この処理は以下の点がめんどくさいので一旦不完全として進めて行きたいと思います。

  • package.jsonmodule, browser, exportsフィールドには対応しない
  • node_modulesのディレクトリ昇格処理は行わない
function getScriptFilePath(basePath, filename) {
  if (isNodeModule(filename)) {
    return join(basePath, getFilename(filename));
  }

  // node_modules
  const moduleBasePath = join(basePath, 'node_modules', filename);

  // e.g. require('a/b')
  // need to split by /
  if (filename.includes('/')) {
    const dir = dirname(moduleBasePath);
    const name = basename(moduleBasePath);

    return join(dir, getFilename(name));
  }

  // TODO: add module, browser, exports
  const { main } = require(join(moduleBasePath, 'package.json'));

  // when main field is undefined, index.js will be an entry point
  return join(moduleBasePath, getFilename(main));
}

使われるモジュールのマップを作成する

これでバンドラーからモジュールへのファイルアクセスが行えたので、そのモジュールの中身を取得しASTに変換し、保持します。ここでは以下の情報を整理します。

  • id => moduleに振られたユニークid
  • path => 絶対パスrequire('./module1')./module1での検索は困難で書き方が多く一意でないため絶対パスが検索キー
  • ast => 後から再度使うのでASTの形で保持しておく
const modulesMap = new Set();

// エントリーポイントは0番
modulesMap.add({
  id: 0,
  path: entryPath,
  ast: entryCodeAst,
});

ここで最初にモジュールをすべて把握するのかというと、あとからすべてのコードのrequireにモジュールのIDを振っていくため一度すべてのモジュールの対応表を作らなければなりません。

filenameをidにしない理由
  • メリット
    • どのファイルが読まれているかわかりやすい
    • requireの引数を置換しなくてもいいので、実装が楽
      • 最終的にrequireを実行して走査するので、idの対応付けが必要でファイル名をidにすると単純なコードでは動く
  • デメリット
    • bundleサイズが大きくなる
    • ファイル階層、ファイル名の省略、拡張子の省略で一意な値にならない
      • require('xxxx) のxxxxとキー名を一緒にするのはコード内ですべて統一されている保証がないので危ない
      • 絶対パスに変換すればこの問題は解決するが、結局ソースコード内の変更を行っているしbundleサイズが大きくなるので無駄

なので、モジュールそれぞれに数値idをふることにより上記の問題を解決するのが一般的です。

モジュールをキャッシュする

これを行うことにより、すでに読み込まれたモジュールの追加を防ぎ、Circular Dependency防ぎます。
以下の場合の対応を行わないと再帰が終わらずに無限にループします。

Circular Dependencyの例

// entry.js
module.exports = 'from entry';

const a = require('./module1');

console.log('main:', a);
// module1.js
const a = require('./entry');

console.log('module1:', a);

module.exports = 'from module1';

以下のように現在走査しているファイルの絶対パスを使って確認を行います。

const hasAlreadyModule = Array.from(modulesMap).some(({ path }) => path === filePath);

無ければ、追加し、そのモジュールはキャッシュされていないのでそのモジュールの依存を辿るために再帰を行います。

if (!hasAlreadyModule) {
  try {
    // node_modulesだったら現在のベースパスをリセット
    const nextDir = isNodeModule(args[0].value) ? entryDir : dirname(filePath);
    const code = readFileSync(filePath, 'utf-8');
    const ast = parser.parse(code);

    modulesMap.add({
      id: modulesMap.size, // これで自動的にIDがインクリメントされていく
      ast,
      path: filePath,
    });
    walkDeps(ast, nextDir);  // まだ見てないモジュールの中身を見に行く
  } catch (e) {
    console.warn('could not find the module:', e.message);
  }
}

全体コード

async function buildModulesMap(entryDir, entryFilename) {
  const modulesMap = new Set();
  const entryPath = getScriptFilePath(entryDir, `./${entryFilename}`);
  const entryCodeAst = parser.parse(await promises.readFile(entryPath, 'utf8'));

  // add an entry point
  modulesMap.add({
    id: 0,
    path: entryPath, // an absolute path
    ast: entryCodeAst,
  });

  // start from the entry-point to check all deps
  walkDeps(entryCodeAst, entryDir);

  function walkDeps(ast, currentDir) {
    traverse(ast, {
      CallExpression({ node: { callee, arguments: args } }) {
        if (callee.type === 'Identifier' && callee.name === 'require') {
          const filePath = getScriptFilePath(currentDir, args[0].value);
          const hasAlreadyModule = Array.from(modulesMap).some(({ path }) => path === filePath);

          if (!hasAlreadyModule) {
            try {
              // reset the current directory when node_modules
              // ./ has 2 types which are local of the first party and local of the third party module
              const nextDir = isNodeModule(args[0].value) ? entryDir : dirname(filePath);
              const ast = parser.parse(readFileSync(filePath, 'utf-8'));

              modulesMap.add({
                id: modulesMap.size,
                ast,
                path: filePath,
              });

              walkDeps(ast, nextDir);
            } catch (e) {
              console.warn('could not find the module:', e.message);
            }
          }
        }
      },
    });
  }

  return modulesMap;
}

2. すべてのコードのrequireをidに置換する

ここでのゴールは、先程生成したmodulesMapのコードのrequireの中身をすべてidを書き換えることです。

先程作成した以下の情報を使っていきます。

const modulesMap = new Set();

modulesMap.add({
  id: 0,
  path: entryPath, // 絶対パス
  ast: entryCodeAst,
});

それをすべて回しつつ、ASTがすでにあるので再度トラバースを行い自身(modulesMap)の中に入っている他のモジュールを探しそのIDをrequire部分を上書きします。

e.g. ./module.js === 1(id) => require('./module') ===>require(1)

for (const { id, ast, path } of modulesMap.values()) {
  traverse(ast, {
    CallExpression({ node: { callee, arguments: args } }) {
      if (callee.type === 'Identifier' && callee.name === 'require') {
        const filePath = getScriptFilePath(
          // node_modulesのときはプロジェクトのベースパスではなく、そのモジュールのpathをベースにする
          isNodeModule(args[0].value) ? dirname(path) : basePath,
          args[0].value
        );
        const { id: moduleID } =  // ここでrequireの中身のファイルIDを手に入れる
          Array.from(modulesMap.values()).find(({ path }) => path === filePath) || {};

        // requireの引数の中身を変更する
        args[0].value = moduleID; // './xxxx' => 0 等の数字(moduleID)へ置換する
      }
    },
  });
}

最終的な展開式は、各モジュールのidcodeだけあればいいので、以下のように管理します。
pathはなくてもいいですが、bundleされたファイルにコメントでファイル名書いてあげるとわかりやすいので入れておいたほうがいいです。

const modules = new Map();

modules.set(id, {
  path,
  code: moduleTemplate(generate(ast).code),
});

実行で読み込まれるファイルのすべての依存を解決し、一意なモジュールのidに置換が行えました。

全体コード

function convertToModuleId(basePath, modulesMap) {
  const modules = new Map();

  for (const { id, ast, path } of modulesMap.values()) {
    traverse(ast, {
      CallExpression({ node: { callee, arguments: args } }) {
        if (callee.type === 'Identifier' && callee.name === 'require') {
          const filePath = getScriptFilePath(
            // don't reset the path when node_modules
            // because the path during searching in node_modules is the base path of modulesMap
            isNodeModule(args[0].value) ? dirname(path) : basePath,
            args[0].value
          );
          const { id: moduleId } =
            Array.from(modulesMap.values()).find(({ path }) => path === filePath) || {};

          args[0].value = moduleId;
        }
      },
    });

    modules.set(id, {
      path,
      code: moduleTemplate(generate(ast).code),
    });
  }

  return modules;
}

ランタイムのコード作成

最後に二種類の実行コードの作成を行います。

  • モジュールテンプレート
  • 本体テンプレート(IIFE)

モジュールテンプレート

モジュールは以下のように展開されます。

// before 
function m(txt) {
  console.log('module', txt);
}

module.exports = m;
// after
{
  [id]: function (module, exports, require) {
    function m(txt) {
      console.log('module', txt);
    }

    module.exports = m;
  }
}

このように引数のmodule, exports, requireを持った関数に囲います。
これは後ほど、本体の引数として使われます。

本体テンプレート

上記で作成したモジュールテンプレートをvalueとして保持し、keyをそのモジュールのidとしたオブジェクトをIIFEの引数に渡します。

((modules) => {
  const usedModules = {};

  function require(moduleId) {
    if (usedModules[moduleId]) {
      return usedModules[moduleId].exports;
    }

    const module = (usedModules[moduleId] = {
      exports: {},
    });

    modules[moduleId](module, module.exports, require);

    return module.exports;
  }

  return require(0); // この0はエントリーポイントの0
})({
  // ここからは上のテンプレート
  0: function (module, exports, require) {
    const m = require(1);

    m('from entry.js');
  },
  1: function (module, exports, require) {
    function m(txt) {
      console.log('module', txt);
    }

    module.exports = m;
  },
  // ここまで
});

これでモジュール解決も行えて、1ファイルで実行できる形となりました。
これが最低限のベースコードとなります。

処理フロー

1 引数に設定しているモジュール群が本体コードに渡りすべての実行が始まる

((modules) => {

2 最初に return require(0); が実行される 0(entry pointのmoduleId)はすでにこのコードを生成する時にセットしておく

((modules) => {
  ...

  return require(0); 
})({...});

3 require(0)を実行する

ここの処理は再帰的に行われるため以下共通

const usedModules = {};

function require(moduleId) {
  if (usedModules[moduleId]) {
    return usedModules[moduleId].exports;
  }

  const module = (usedModules[moduleId] = {
    exports: {},
  });

  modules[moduleId](module, module.exports, require);

  return module.exports;
}
  • 最初にキャッシュ用の変数(usedModules)を確認する(今回は最初なので空)
    • キャッシュ変数のキーはmoduleIDなので、もしあればその中のexportsを返して終わり

--- ここからはキャッシュが無い時の処理 ----

  • 次にキャッシュ変数に今の引数のmoduleIdの値を入れ初期化(次回以降のキャッシュのため)
    • 出口のexportsだけあれば全部のモジュールがつながるのでそれだけ初期化する
const module = (usedModules[moduleId] = {
  exports: {},
});
  • 次に以下の処理を実行する
// nodeには、exportsとmodule.exportsがあるため、第1引数と第2引数を使いmodule.exportsに格納する
// requireは走査用ラッパー
modules[moduleId](module, module.exports, require);
  • ここで第3引数にrequireを渡しているため再帰的に走査を行いキャッシュ変数に使ったモジュールを貯めつつ実行をしていきます
0: function (module, exports, require) {
  // 引数経由で来たIIFE内のrequire functionがここで実行され、1の走査が始まる
  // 1の中にrequireがあれば更にそれが呼ばれる を繰り返しコードを組み立てる
  // require functionの戻り値はキャッシュ変数に格納されたexports
  const m = require(1); 

  m('from entry.js');
},

このようにエントリーポイントからスタートし、上から順に再帰的にモジュールからモジュールへ呼び出しを行い実行していきます。

bundleされたコード例

'use strict';

const { version } = require('react');

console.log(version);

これの変換後は以下のようになります。

the-sample-of-module-bundler/nm.js at master · hiroppy/the-sample-of-module-bundler · GitHub

これを見ればわかりますが、tree shaking/dead code eliminationの必要性が出てきます。

リポジトリ

すべてのコードはこのリポジトリにあります。

github.com

さいごに

次回は、tree shaking + ESMについて書こうと思います。

webpack@5で入るModule Federationについて

💁‍♀️ ブログが移管されたので,新しい方へ移動します。

Module Federation(以下 mfe)はwebpack@5から入る新しい仕組みの一つです。

Proposal

github.com

目的

アプリケーションを作る時に、webpackはビルド時のソースコードは使う前提で実行するので、様々な最適化を行うことができます。
もし、node_modules経由以外でライブラリを使うという場合はscriptタグやESMから取得するというのが一般的です。

whatwg.github.io

しかし、この場合だと問題点が出てきます。それはライブラリの重複問題です。
取得するライブラリはすでにbundle済みなため、その中には同じライブラリが存在することが多いです。
ユーザーは重複するライブラリ(e.g. react, react-dom, etc.)の取得を行う可能性が高いため、その最適化を行うのがModule Federationです。

上記の図だと、main.jsで使っている緑のライブラリはlib-aでもlib-bでも使っていて、それを3つダウンロードするのは無駄なのでmain.jsがダウンロードしている緑にlib-a/bが依存すればネットワークの最適化ができます。

DLLPluginやexternalsでも同様のことはできますがそれらは貧弱なので今後はこちらに乗り換えることが可能なケースがあります。

この記事では内部は深く触れないため、内部アルゴリズムが知りたい人はこちら

github.com

コード: containers

この仕組みはMicro Frontendsのためだけにあるわけではありませんが、 一番機能する可能性が高いとは思います。

Micro Frontendsとはなにか?

micro-frontends.org

用語

  • ローカル(モジュール) => 使う側
    • ビルドラインに入っている通常モジュール
  • リモート(モジュール) => 使われる側
    • 実行時にコンテナーから呼び出されるモジュール
  • コンテナ => ローカル、リモートにそれぞれいるマネージャー
    • モジュールの前段にいるもの、実際にはリモートのURLではなくリモートのコンテナURLって言ったほうが正しい
    • ここでモジュールの公開をするかどうかを制御する
    • コンテナ間での循環的な依存が可能で、このコンテナが上書きAPI(__webpack_override__)を提供する
      • 兄弟関係でのみ上書きは可能で、単一方向の操作
    • コンテナは以下の処理を行います
      • 非同期チャンクの読み込み
      • そのチャンクの評価

注意

リモートモジュールは、非同期チャンクとなりチャンクロードの処理が必要なため基本的には、import()が使われますが、require()require.ensure()にも使用可能です。
top-levelでのimportもビルドはできますが、同期チャンクとなってしまうため実行時にはエラーとなります。
つまり、この仕組みはbundle時にまだ不明なプログラムを許容するため、実行時に初めてエラーがわかります。

そして、ローカルもリモートになることが可能なため、各チャンクは立ち位置がその場の状況によって変化します。
ローカルと決めてたチャンクにexposesを設定すればそれがリモートチャンクとなるということです。

この場合だと、左のノードがその例で、/subへアクセスした場合はLocalという扱いになりますが、/へアクセスすると左のノードはリモートという扱いになります。(下のノードから見てリモート)

設定とコード

先にコードを見ていきましょう。この構成では、リモートのファイルをローカルが取るシンプルな例です。

リポジトリ

github.com

リモート

// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  output: {
    // このリモートのファイルがローカルで展開されるときにpublicPathを書かないと親の実行時に親のURLを見てしまうので必須
    // このPRで変更される: https://github.com/webpack/webpack/pull/10703
    publicPath: 'http://localhost:8081/',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'page1', // エントリーの名前、exposesがある場合は必須キー
      library: {
        type: 'var', // scriptタグを経由する、他のオプションはこちら https://github.com/webpack/webpack/blob/dev-1/schemas/plugins/container/ModuleFederationPlugin.json#L155
        name: 'page1' // importされるときの名前(この場合は、import('page1/xxxx'))
      },
      filename: 'page1RemoteEntry.js', // 出力されるentryのファイル名
      exposes: {
        Page: './src/index.js', // コンポーネント名(この場合は、import('page1/Page'))
      },
    }),
  ],
};
// src/index.js
import React from 'react';

const Page1 = () => <h1>This is Page1</h1>;

export default Page1;  // React.lazyはdefault exportsのみ許容
              Asset       Size
             579.js   7.27 KiB  [emitted]
 579.js.LICENSE.txt  295 bytes  [emitted]
            main.js   7.68 KiB  [emitted]            [name: main]
main.js.LICENSE.txt  295 bytes  [compared for emit]
page1RemoteEntry.js   2.09 KiB  [emitted]            [name: page1]

ローカル

// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: './index.html',
    }),
    new ModuleFederationPlugin({
      remotes: {
        page1: 'page1', // page1をローカル側で使用することを伝える、import('page1/xxx')
      },
    }),
  ],
};
// src/index.js
import React, { lazy, Suspense } from 'react';
import { render } from 'react-dom';

// page1/page(remote)をdynamic import
const Page1 = lazy(() => import('page1/Page'));

const Wrapper = () => (
  <div>
    <Suspense fallback={<span>Loading...</span>}>
      <Page1 />
    </Suspense>
  </div>
);

render(<Wrapper />, document.getElementById('root'));
<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <div id="root"></div>
    <!-- import('page1/page'))で読み込むためにremoteのファイルを取得 -->
    <script src="http://localhost:8081/page1RemoteEntry.js"></script>
  </body>
</html>
              Asset       Size
         index.html  194 bytes  [compared for emit]
            main.js    128 KiB  [emitted]            [name: main]
main.js.LICENSE.txt  790 bytes  [compared for emit]

このように、import('<scope>/<request>')という形でローカル側は取り込みます。

依存関係共有

sharedというオプションをつけることにより、依存関係の共有を行い、リモートはローカルの依存関係を優先的に参照します。
もしローカルに依存関係がない場合、リモートは独自にダウンロードを行います。
この仕組みにより、バンドルを跨いだ関係でも最小限にファイル量を留めることができます。

上記のコードでは、このオプションを入れる前のNetworkは以下のようになります。

579.jsというリモートのJSに注目してください。このファイルはpage1RemoteEntry.jsから呼び出されます。
この579.jsにReactのライブラリソースコードが入っています。(main.jsの中にも同様のコードが入っている)

では、ローカルもリモートもReactを使っているので、両者のwebpack.config.jsに以下を追加します。

new ModuleFederationPlugin({
  ...,
  shared: ['react'],
]

そうすると、以下のように579.jsのサイズが3.4kbから506bまで下がりました。
これは、Reactライブラリのソースコードがなくなり、579.jsがmain.jsに含まれているReactのライブラリコードを参照しているという状態です。
この579.jsに残っているコードはconst Page1 = () => <h1>This is Page1</h1>;のみとなります。

では、リモート側にはshared: ['react']を付け、ローカル側をなくすとどうなるでしょう?
最初に説明したとおり、ローカル側がsharedを設定してなかった場合は、リモート側が自身のURLからReactライブラリをダウンロードするようにフォールバックが行われます。

この466.jsがReactのライブラリコードとなります。
これらはコンテナであるpage1RemoteEntry.jsが管理していて、リモートに同一のライブラリがないのでこのコンテナが466.jsをダウンロードする処理を行います。

まとめると、リモートは共有されるであろうライブラリはsharedに入れておいた方が管理が楽だと思います。 そうすると使う側がそれを許容するかどうかを管理できるためです。

Q&A

Q: URLはHTMLに書く必要あるの?
A: html-webpack-pluginの対応を待つかwebpackでcontainerのurlが引けるためそれをhtmlにわたす実装を書く

Q: 共有ライブラリのバージョンが異なる場合どうすればいいの?
A: 標準ではないが、以下のような書き方はできる。

shared: {
  "react@6": "react"
}

Q: ハッシュ付きファイル名の場合どうすればいいの?
A: webpack間でのオーケストレーションを維持するためハッシュ付きファイル名は出力されません

Q: ローカルとリモートで共通ライブラリを管理するのだるい
A: 今後自動的に入れる仕組みが入ると思う。3rd partyではすでに存在するらしい。ただ手で書いたほうがいい気はする

Q: IDEでの補完が効きません。
A: わかります、けどこれwebpack.config.jsをIDEが理解できるようにならないと解決しない

Q: SSRでも動くか?
A: 設計上、webに限定して作ってないので動く。library.typevarからcommonjs-moduleに変えてみて。

さいごに

ユーザーはこのsharedさえ知っていればよくてcontainersとかは基本的には知らなくていいです。
まだ安定的なフェーズではなく実験的なのでインターフェイスの変更には注意してください。
なにか聞きたいことあれば、twitterまで。

webpackのinline syntax

誰得かわからないですが、これの質問が来たのでここで説明しようと思います。
webpackは、ローダーチェインと呼ばれる仕組みで動いています。
これには、inlineの書き方が存在しますが開発者ですら使うのは非推奨です。
しかし、webpackのログにはこの表記が多々出てくるため疑問がある人は多いと思います。

設定は以下のファイル群です。

// webpack.config.js
module.exports = {
  mode: 'development',
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [
          'style-loader',
          'css-loader',
        ],
      },
      {
        test: /\.css$/i,
        use: [require.resolve('./loader')],
        enforce: 'post',
      }
    ],
  },
};
// loader.js
module.exports = function postLoader(res) {
  console.log(this.resource);

  return res;
}

この場合は、拡張子.cssがきた場合にcss-loader -> style-loader -> ./loaderというローダーチェインが発生します。

基本

webpackのローダーの読み方は右からとなり、optionsのつなぎはqueryです。
また、inlineはwebpack.config.jsに書かれたものよりも優先度が高く上書き可能です。
以下の場合だと、css-modulesとsourceMapが上書きされ有効化されます。
ローダー間は!でセパレートします。

import style from 'style-loader!css-loader?modules&sourceMap!./style.css';

接頭辞

webpackではprefixにつけるものによって内容が変化します。

!

import style from '!style-loader!css-loader?modules&sourceMap!./style.css';

先頭に!をつけることにより、webpack.config.jsに書かれたローダーの設定群を無効にします。

!!

import style from '!!style-loader!css-loader?modules&sourceMap!./style.css';

先頭に!!をつけることにより、!に加えenforcepre, postを無効にします。
なので、この記事の例だと./loaderの呼び出しも!!にすることにより無効化出来ます。

!!!...

これ以降はまだ予約なし。

-!

import style from '-!style-loader!css-loader?modules&sourceMap!./style.css';

先頭に-!をつけることにより、!に加えenforcepreを無効化します。(postのみ動く)
この記事の場合、./loaderpostで設定されるため呼び出しが行われます。

Match Resource

!=!を使うことにより、リソースのmatchを行います。

import style from './style.js!=!style-loader!css-loader?modules&sourceMap!./style.css'

この場合は、cssで処理してたものを強制的にjsと認識させます。
なので、実際にはstyle.jsというファイル名は存在しません。

これは主にModule Typeを変更するときに使われます。
例えば、jsonファイルをjsに変換してなにか処理を続行する場合等。

上の例で上げたwebpack.config.jsを見てみます。

module.exports = {
  mode: 'development',
  module: {
      ...
      {
        test: /\.css$/i,
        use: [require.resolve('./loader')],
        enforce: 'post',
      }
    ],
  },
};
// loader
module.exports = function postLoader(res) {
  console.log(this.resource);

  return res;
}

Match Resouceは変換先の拡張子をwebpack.config.jsへ渡すため、この場合のローダーの出力は何も出ません。

さて、以下のようにキャッチするのをcssではなくjsに変更します。

module.exports = {
  mode: 'development',
  module: {
      ...
      {
-        test: /\.css$/i,
+        test: /\.js$/i,
        use: [require.resolve('./loader')],
        enforce: 'post',
      }
    ],
  },
};

ローダーの出力は以下のようになります。

/webpack/src/index.js
/webpack/src/style.css <----- !!!!!
/webpack/node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js
/webpack/node_modules/css-loader/dist/runtime/api.js

でてきました。すごい不思議だとは思いますが、testの条件にマッチしていないように見えます。
importのファイル名は.cssなのにtest: /\.js$/iのローダに入っていることとなります。
この場合は、match resouceによって変換されていることが読み取れます。

最後に

覚えなくていいです。

2019年を振り返る

blog.hiroppy.me

2019年は人生の中で一番大きな年でした。
3月から8月までアイルランドの方に住んでいて、人生でもう一度あるかないかわからない経験をしました。
大きな経験と様々な刺激があり、日本に帰ってこれてよかったと思っています。

blog.hiroppy.me

ブログ

今年はブログの投稿数は少なく、18記事となりました。

結構メタい記事を書いたのも今年な気がします。(普段は書かないので)

blog.hiroppy.me

OSS

自分のリポジトリのスター数が3600近くになりました。
この年で200 -> 3400に増えたのは他ならぬユーザーさんが使ってくれたり宣伝してくれたおかげです。
ありがとうございました。

github.com

また、webpackのメインに復帰したのも今年です。
現在は、webpack@v5のリリースに向けて、調整中です。

スポンサーも始めましたのでもし応援してくれる方いたらぜひ!

github.com

登壇

アイルランドの方で、招待されて30分の登壇を行ってきました。
初海外登壇をやっとできました!

blog.hiroppy.me

私生活

7月から10月までプリコネにめっちゃハマっていました。

blog.hiroppy.me

本当に楽しかったですが、社会人になり私生活が忙しくなると自分の中でソシャゲに割く時間がなくなってしまいフェードアウトしてしまいました。

2020年

来年は私生活を充実させることに注力させます。

Node.jsをPolicyにより安全に実行する

この記事は、Node.js Advent Calendar 2019の18日目です。

Policy とは?

nodejs.org

実行コードを制御するセキュリティ機構がNode.jsに入りました。
ポリシーファイルを使い、整合性のチェックを行います。
これにより、requireしたときにファイルが変化していないことを保証することが可能です。

docs.google.com

アルゴリズムは、w3cにあるSRIと同様です。

www.w3.org

github.com

この機能は、まだ実験中のフェーズなためフラグが必要となります。

使い方

// app.js

console.log('app');

require('fs');
require('./foo.js');
// foo.js

console.log('foo');

最初に各ファイルのハッシュ値を生成する必要があります。

$ printf "sha384-$(cat app.js | openssl dgst -sha384 -binary | base64)"
sha384-stoY+K7ZeOkSLHmCYOOrfWcLsKFb1Niv/dkz6f9Q0UP5FQi9pRTglvtGcQ/IPyZR

$ printf "sha384-$(cat foo.js | openssl dgst -sha384 -binary | base64)"
sha384-1RHAoU62Cn5DMHZLSZZKEnvcml+GOtj5O4BXAUohiYtjGzo0v/iFIM7KOxRwoG7P

policy.jsonを作成し、以下のように登録します。
これは、実行ファイルすべて列挙する必要があります。(entry-point含め)

{
  "resources": {
    "./app.js": {
      "integrity": "sha384-stoY+K7ZeOkSLHmCYOOrfWcLsKFb1Niv/dkz6f9Q0UP5FQi9pRTglvtGcQ/IPyZR",
      "dependencies": {
        "fs": true,
        "./foo.js": "./foo.js"
      }
    },
    "./foo.js": {
      "integrity": "sha384-1RHAoU62Cn5DMHZLSZZKEnvcml+GOtj5O4BXAUohiYtjGzo0v/iFIM7KOxRwoG7P"
    }
  }
}

dependencies は booleanでも可能ですが、本番環境では推奨されません。
今回は、ビルトインモジュールのfsなのでtrueとしています。
各ファイルでrequireするモジュールファイルは、すべて列挙する必要性があります。

Dependency Redirection(dependencies)は、require.cachemodule.constructor等のアクセスに対しては、防ぐことができません。
つまり、requireのみのチェックを行うということになります。

以下のようにpolicy.jsonを指定して実行します。

$ node --experimental-policy=policy.json app.js

まとめ

厳格に行うのであれば、コードよりハッシュ値は変わるためPolicyを使うのは有効です。
しかしこれを一つづつ行うのは大変なため、何かしらのCLIで一括で行える機構が必要です。
まだユーザーランドには自分が知る限り存在しないため今後に期待です。

SPA + SSR + PWA の作り方とセキュリティについて

一年前に以下の記事を書いて、その後放置していたら多くのライブラリのメジャーリリースで完全に動かなくなってしまったのでリニューアルしました。

blog.hiroppy.me

以下のセクションで説明していきますが、コードを読んだほうが早いです。

リポジトリ

github.com

このリポジトリはこれを見れば様々な実装の動く土俵を作れるというのを目的としています。
環境構築ですら毎回忘れるので。。
なので冗長に書いてある部分も多いですが、今後も新しい必要な実装を小さく追加していく予定です。

動きを確認したい人は、cloneして手元で動かしてみてください。

技術スタック

これはあくまでもサンプルなので、sagaとapolloが混じってますが実際はどちらかで大丈夫です。
SPAのベースはredux, sagaで設計していて、apollo-stateは使わずあくまでもqueryとmutationのみです。

主要なライブラリは以下のとおりです。

deps devDeps
react typescript
redux webpack
react-router babel
react-helmet storybook
redux-saga storyshots
styled-components jest
loadable-components testing-library
apollo-boost nodemon
express prettier
nanoid workbox
typescript-eslint
autocannon

Server Side Rendering

注目するべき点はloadable-componentsの大幅なアルゴリズム改善だと思っています。
これにより、パフォーマンスは改善されました。

loadable-components

github.com

未だ、ReactのほうがSSRに対応していないため、引き続きreact-loadableやloadable-componentsは必要となります。

SuspenseはSSR対応進めています。

github.com

メジャーバージョンでreact-loadableと同様にwebpackを使いassetsのmapを作成するようになりました。
これにより、SSRの処理が高速化されました。
しかし、babel-pluginに依存しないと動かないため、babel-preset-typescriptをこのライブラリでは使っています。

データの送り方

SSRで取得したデータはStoreを構築し、それをクライアントサイドにHTML経由で渡します。

以下のようにdata属性を使い、scriptタグ経由で渡すのがいいと自分も思っています。

qiita.com

<script nonce="xxxxx" id="initial-data" type="text/plain" data-json="${preloadedState}"></script>

このpreloadedStateエスケープ処理が必要なので注意してください。

クライアント側の読み込み方

const initialData = JSON.parse(document.getElementById('initial-data')!.getAttribute('data-json')!);
const { store } = configureStore(initialData);

ssr-sample/index.tsx at master · hiroppy/ssr-sample · GitHub

useEffect

SSRでは、componentDidMount前までしか実行されません。
つまり、hooksではuseEffectは呼び出されずFCにはconstructorは存在しません。
一体どこに初期化処理とかあれば書けばいいのかベストプラクティスは自分はわかりません。

  if (!process.env.IS_BROWSER) {
    dispatch(loadSagaPage(maxLength));
  } else {
    useEffect(() => {
      dispatch(loadSagaPage(maxLength));
    }, []);
  }

今はこんなふうに書いているけど気持ち悪いので辞めたい。。

レンダリングコード

// ここでassetsのmapを取得する
const statsFile = resolve(
  __dirname,
  process.env.NODE_ENV !== 'production'
    ? '../../../../dist/client/loadable-stats.json'
    : '../../../../client/loadable-stats.json'
);

export async function get(req: Request, res: Response) {
  const baseUrl = `${req.protocol}://${req.get('Host')}`;
  const { nonce }: { nonce: string } = res.locals;
  const { store, runSaga } = configureStore();
  const client = createClient({ link: new SchemaLink({ schema }) });
  const sheet = new ServerStyleSheet();
  const context = {};

// Node.jsでは完全なurlが必要なのでstoreにわたす
  store.dispatch(setBaseUrl(baseUrl));

  const App = () => (
    <ApolloProvider client={client}>
      <Provider store={store}>
        <StaticRouter location={req.url} context={context}>
          {/* add `div` because of `hydrate` */}
          <div id="root">
            <Router />
          </div>
        </StaticRouter>
      </Provider>
    </ApolloProvider>
  );

  try {
    const extractor = new ChunkExtractor({ statsFile });
    // assets mapがあるのでrenderToStringを走らせる必要がなくなった
    const tree = extractor.collectChunks(<App />); 

    await Promise.all([
      // react-apolloの処理をキックすることにより、redux-saga, react-helmet, styled-componentsの処理を実行
      getMarkupFromTree({
        tree,
        renderFunction: renderToStaticMarkup // あくまでも処理を実行するためなので軽量なstaticMarkupで良い
      }),
      // 上記のrenderToStaticMarkupで実行され、sagaの終了を待つ
      runSaga()
    ]);

    const body = renderToString(tree);  // ここでクライアントに渡すhtmlのレンダリングを行う

    // ここからはhtmlに埋め込むscriptタグの生成やstoreのデータをクライアントに渡すためのjson等を作成
    const preloadedState = JSON.stringify(store.getState());
    const helmetContent = Helmet.renderStatic();
    const meta = `
      ${helmetContent.meta.toString()}
      ${helmetContent.title.toString()}
    `.trim();
    const style = sheet.getStyleTags();
    const scripts = extractor.getScriptTags({ nonce });
    const graphql = JSON.stringify(client.extract());

    return res.send(renderFullPage({ meta, body, style, preloadedState, scripts, graphql, nonce }));
  } catch (e) {
    console.error(e);
    return res.status(500).send(e.message);
  }
}

ssr-sample/renderer.tsx at master · hiroppy/ssr-sample · GitHub

Single Page Application

SPAのベースはreduxのstore(or apollo-state), routingがreact-router, 副作用の操作をredux-sagaでこのサンプルは行っています。

hooks

reactにhooksが入ったことにより、react-routerやredux、apolloのhooks対応されました。

export const Saga: React.FC = () => {
  const dispatch = useDispatch(); // reduxのdispatch
  const samples = useSelector(getSagaCode);  // reduxのselector
  const { search } = useLocation(); // react-routerでlocationを取得
  const maxLength = new URLSearchParams(search).get('max');

  if (!process.env.IS_BROWSER) {
    dispatch(loadSagaPage(maxLength)); // actionを実行し、typeとpreloadをdispatchへ(containersが行っていたこと)
  } else {
    useEffect(() => {
      dispatch(loadSagaPage(maxLength));
    }, []);
  }

  const like = useCallback((id: number) => { // reactのuseCallback
    dispatch(addLike(id));
  }, []);

  return (
    <>
      <Head title="saga-page" />
      <p>get => get all samples</p>
      <p>post => add a like count</p>
      {samples.length !== 0 && <CodeSamplesBox samples={samples} addLike={like} />}
    </>
  );
};

ssr-sample/Saga.tsx at master · hiroppy/ssr-sample · GitHub

redux

reduxはhooksが入ったことにより大きな変更があります。
それは、presentationalとcontainerという単語が無くなりそうです。

github.com

今までのreduxは、presentationalとcontainerで責務(関心事)が別れていました。
それは自分にとってはきれいだと思っていました、presentationalでstoreからくる値はpropsを渡す感じ。

しかし、hooksが入ったことにより、dispatchをpresentationalから呼ぶことになったのでcontainerも必要ないです。

apollo

apolloは本当にきれいに書くことができるようになり満足しています。

移行記事は以下を参考にしてください。

blog.hiroppy.me

export const GET_SAMPLES = gql`
  query getSamples($maxLength: Int) {
    samples(maxLength: $maxLength) {
      id
      name
      code
      likeCount
      description
    }
  }
`;

export const ADD_LIKE = gql`
  mutation addLike($id: Int) {
    addLike(id: $id) {
      id
    }
  }
`;

export const Apollo = () => {
  const dispatch = useDispatch();
  const { search } = useLocation();
  const maxLength = new URLSearchParams(search).get('max');
  const { loading: queryLoading, error: queryError, data: queryData } = useQuery<{ // queryのhooks
    samples: Samples;
  }>(GET_SAMPLES, { variables: { maxLength: Number(maxLength) } });
  const [
    addLike,
    { loading: mutationLoading, error: mutationError, data: mutationData }
  ] = useMutation(ADD_LIKE, { // mutationのhooks
    // ここは実際、refetch行うべきじゃないけど、これサンプルなので手抜きです
    refetchQueries: [{ query: GET_SAMPLES, variables: { maxLength: Number(maxLength) } }]
  });
  const like = useCallback((id: number) => {
    addLike({ variables: { id } }); // mutationを実行
  }, []);

  // SPAをsagaで管理している関係上、ここでもstopだけ行わないといけない
  if (!process.env.IS_BROWSER) {
    dispatch(loadApolloPage());
  }

  return (
    <>
      <Head title="apollo-page" />
      <p>query => get all samples</p>
      <p>mutation => add a like count</p>
      {queryLoading && <p>loading...</p>}
      {queryError && <p>error...</p>}
      {queryData && <CodeSamplesBox samples={queryData.samples} addLike={like} />}
    </>
  );
};

ssr-sample/Apollo.tsx at master · hiroppy/ssr-sample · GitHub

はぁ、きれい。。。(saga捨てたい顔)

redux-saga

sagaが行うこととして、クライアントサイドとサーバーサイドで一点異なる点があります。
それは、sagaのプロセスをサーバーサイドの場合停止させないといけなく、そうしないとクライアントにhtmlを返せません。

なので、以下のように止めるようにします。

function* loadTopPage(actions: ReturnType<typeof LoadTopPage>) {
  yield changePage();
  yield put(loadTopPageSuccess());

  if (!process.env.IS_BROWSER) {
    yield call(stopSaga); // ENDを呼ぶ
  }
}

ssr-sample/pages.ts at master · hiroppy/ssr-sample · GitHub

自分がSPAで実装を行うときは、sagaを2ライン走らせます。

  • 全体を管理するappProcess
    • 読み込み完了、エラー(502, etc...)、どこのページでも行う処理(e.g. login, ga, etc..)
  • 各ページのpageProcess
    • ページ固有の処理(e.g. fetching, etc...)
export function* pagesProcess() {
  yield takeLatest(LOAD_APP_PROCESS, appProcess);
  yield takeLatest(LOAD_TOP_PAGE, loadTopPage);
  yield takeLatest(LOAD_SAGA_PAGE, loadSagaPage);
  yield takeLatest(LOAD_APOLLO_PAGE, loadApolloPage);
}

redux-saga設計 · GitHub

これらが終わり次第、ENDを呼ぶ構築が一番いいと思います。

App Shell

PWAと少し被りますが、sagaとの話もあるのでここで。

react-routerでパスに応じたcomponentsはここに流れてきます。
つまり、このコンポーネントが上位階層で、ここでheaderだけのレンダリング(App)と共通処理(appProcess)を行います。

export const App: React.FC = ({ children }) => {
  const location = useLocation();
  const dispatch = useDispatch();

  // ここはmiddlewareで行う共通の処理(appProcess)を実行(起動させる)
  if (!process.env.IS_BROWSER) {
    dispatch(loadAppProcess());
  } else {
    useEffect(() => {
      dispatch(loadAppProcess());
    }, []);
  }

  // e.g. send to Google Analytics...
  useEffect(() => {}, [location]); // ここでSPA全体のパス変更を監視し、イベントを発火させる(e.g. GA)

  return (
    <>
      <Header /> {/* ここは変わることがない(あってもredux経由でHeader内selectorによる再描画) */}
      <GlobalStyle />
      <Container>{children}</Container> {/* childrenはreact-routerから来たコンポーネント */}
    </>
  );
};

ssr-sample/App.tsx at master · hiroppy/ssr-sample · GitHub

Progressive Web Application

Manifest

PWAのmanifestは、manifest.jsonではなくmanifest.webmanifest というファイル名にします。

developer.mozilla.org

仕様

w3c.github.io

webpack-pwa-manifestを使い、生成します。

// webpack.config.js
    new PwaManifest({
      filename: 'manifest.webmanifest',
      name: 'ssr-sample',
      short_name: 'ssr-sample',
      theme_color: '#3498db',
      description: 'introducing SPA and SSR',
      background_color: '#f5f5f5',
      crossorigin: 'use-credentials',
      icons: [
        {
          src: resolve('./assets/avatar.png'),
          sizes: [96, 128, 192, 256, 384, 512]
        }
      ]
    })

ssr-sample/webpack.client.prod.config.js at master · hiroppy/ssr-sample · GitHub

add to home screen等はデバッグしづらいので、もし原因がわからなかったらchrome://flags/#bypass-app-banner-engagement-checksをオンにすると幸せになります。

PCでのadd to home screenはこんな感じになります。

f:id:about_hiroppy:20191122082702p:plain

Service Worker

Workboxを使います。

developers.google.com

// webpack.config.js
    new GenerateSW({
      clientsClaim: true,
      skipWaiting: true,
      include: [/\.js$/], // 今回出力がjsしかないため
      runtimeCaching: [
        {
          urlPattern: new RegExp('.'), // start_urlに合わせる
          handler: 'StaleWhileRevalidate' // cacheを使い裏でfetchする
        },
        {
          urlPattern: new RegExp('api|graphql'),
          handler: 'NetworkFirst' // ネットワークアクセスを優先する
        },
        {
          urlPattern: new RegExp('https://fonts.googleapis.com|https://fonts.gstatic.com'),
          handler: 'CacheFirst' // cacheを優先する。expire設定したほうがいい
        }
      ]
    })

ssr-sample/webpack.client.prod.config.js at master · hiroppy/ssr-sample · GitHub

後ほど、説明しますが、CSPには注意してください。
service-workerからのアクセスはconnectとなります。

Audits

f:id:about_hiroppy:20191119115332p:plain

Expressのhttp/2対応がマージされれば全部100になります。(or Nginx置いて)

Security

Content Security Policy

developer.mozilla.org

CSPとは、XSSを防ぐために信頼したものしかブラウザが実行しないように制御できます。
サーバーで毎回ハッシュ値を生成し、それをscriptタグにつけhttp headerからContent-Security-Policyの属性を照会し一致するものだけを実行します。
これは、script以外にもcssやfont, images, connect等幅広く設定できます。

今回のサンプルでは、google fontを使うためgoogle fontとreadmeのバッジで使われるshieldsを許可しています。

// google font: https://stackoverflow.com/a/34576000/7014700
const baseDirectives: helmet.IHelmetContentSecurityPolicyDirectives = {
  defaultSrc: ["'self'"],
  styleSrc: ["'unsafe-inline'", 'fonts.googleapis.com'], // for styled-components
  fontSrc: ["'self'", 'data: fonts.gstatic.com'],
  imgSrc: ["'self'", 'img.shields.io'], // for README
  connectSrc: ["'self'", 'img.shields.io', 'fonts.googleapis.com', 'fonts.gstatic.com'], // for service-worker
  workerSrc: ["'self'"]
};

ssr-sample/csp.ts at master · hiroppy/ssr-sample · GitHub

このように指定したところへのアクセスだけを許可することよりXSSに対して強固なwebアプリケーションが作成できます。

このアプリケーションでは、nonce方式を説明していきます。

export function generateNonceId(req: Request, res: Response, next: NextFunction) {
  res.locals.nonce = Buffer.from(nanoid(32)).toString('base64');
  next();
}

f:id:about_hiroppy:20191122082040p:plain

<meta property="csp-nonce" content="ZmZIaTAyQ25rZ2FxVERUVVI4VUhBTUVJc29uTV9leUo=">
<script async data-chunk="components-pages-Top" src="/public/vendors~components-pages-Apollo~components-pages-NotFound~components-pages-Saga~components-pages-Top.bundle.js" nonce="ZmZIaTAyQ25rZ2FxVERUVVI4VUhBTUVJc29uTV9leUo="></script>

もっと詳しく知りたい方はPixivの記事が詳しいので読むと良さそうです。

inside.pixiv.blog

Dynamic Import

CSPの問題点として、dynamic importの対応の難しさが上げられます。
dynamic importの場合、nonceが存在しないためです。
CSPには、level3とlevel2が存在し、level3にはstrict-dynamicという仕組みがありそれがこの問題を解決します。
strict-dynamicでは、nonce付きの実行されたスクリプトの子供はnonceが無くても実行可能となります。

FirefoxChromeではすでにlevel3が対応済みなのでこの問題は解決できますが、level3に対応していないブラウザに対してdynamic importは解決が行なえません。
__webpack_nonce__を使えば、動きますがnonceは本来毎アクセス時にhashを生成しないと攻撃者に推測される可能性があるため、ビルド時ではいけなく根本的な解決ではありません。

// chrome, firefox
const lv3Directives: helmet.IHelmetContentSecurityPolicyDirectives = {
  ...baseDirectives,
  scriptSrc: [(req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'", "'unsafe-eval'"]
};

// safari
const lv2Directives: helmet.IHelmetContentSecurityPolicyDirectives = {
  ...baseDirectives,
  scriptSrc: [
    "'self",
    (req, res) => `'nonce-${res.locals.nonce}'`,
    "'unsafe-eval'",
    "'unsafe-inline'"
  ]
};

Service Worker

service workerからの問い合わせはconnect-srcとなります。
以下のように、styleSrcfontSrcと同じurlがconnectSrcに書いてあるのがわかります。

const baseDirectives: helmet.IHelmetContentSecurityPolicyDirectives = {
  defaultSrc: ["'self'"],
  styleSrc: ["'unsafe-inline'", 'fonts.googleapis.com'], // for styled-components
  fontSrc: ["'self'", 'data: fonts.gstatic.com'],
  imgSrc: ["'self'", 'img.shields.io'], // for README
  connectSrc: ["'self'", 'img.shields.io', 'fonts.googleapis.com', 'fonts.gstatic.com'], // for service-worker
  workerSrc: ["'self'"]
};

GraphQL

GraphQLはスキーマが自由であるため、サーバーへの負荷対策を行う必要があります。
例えば、入れ子の深い不正クエリーが送られてきたときにはサーバー側の処理に負荷がかかる可能性があるため、その処理に到達させる前に弾く必要があります。
DoSを防ぐためにも必ず入れる対策がこの対策です。

有名な方法は以下のとおりです。

  • ホワイトリスト
    • リストに書かれたクエリのみを通過させる
  • 深さ制限
    • 指定したクエリの深さ以下を通過させる
  • 重み(コスト)制限
    • クエリーに重さ(深さ含む)付けをし、それの合計値が指定値以下の場合は通過させる

このサンプルでは、重み制限を使用しています。

例えば、今回は使ってないですがgraphql-validation-complexityであれば以下の計算式となります。(実際、fragments等でもう少し難しくなりますが)

// Conclusion
// Field: 1
// root: scalarCost * 1
// not root: objectCost * 1
// list: listFactor * 10

// query {
//   a {       # * objectCost
//     a1a     # * scalarCost
//     a1b {   # * objectCost
//       b1a   # * scalerCost
//       b1b   # * scalerCost
//     }
//   }
//   arr {     # * objectCost
//     arr1 {  # * objectCost * listFactor
//       name  # listFactor
//     }
//     arr2 {  # objectCost * listFactor
//       name  # listFactor
//       id    # listFactor
//     }
//   }
// }

// a * objectCost + a.a1a * scalarCost + a.a1b * objectCost + a.a1b.b1a * scalerCost + a.a1b.b1b * scalerCost
// + arr * objectCost + arr.arr1 * objectCost * listFactor + arr.arr1.name * listFactor
// + arr.arr2 * objectCost * listFactor + arr.arr2.name * listFactor + arr.arr2.id * listFactor

github.com

今回は、graphql-query-complexityを使っています。

  const apollo = new ApolloServer({
    plugins: [
      {
        requestDidStart: () => ({
          didResolveOperation({ request, document }) {
            const complexity = getComplexity({
              schema,
              query: request.operationName
                ? separateOperations(document)[request.operationName]
                : document,
              variables: request.variables,
              estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })]
            });

            // graphqlのスキーマのコストがlimitCost(今回は10)以上であれば、throwし中断させる
            if (complexity >= limitCost) {
              throw new Error(`${complexity} is over ${limitCost}`);
            }
            console.log('Used query complexity points:', complexity);
          },
          didEncounterErrors(err) {
            console.error(err);
          }
        })
      }
    ]
  });

ssr-sample/apollo.ts at master · hiroppy/ssr-sample · GitHub

GraphQLはプロダクションでリリースするときには必ず、このような対策が必要となります。

おわり

長文になりましたが、機能追加等のPR/Issue歓迎しています。
また、もし興味あればGitHub Sponsorsもよろしくおねがいします。

github.com

あと、webpack@5楽しみ🥰