LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


アプリケーションコードに変更を加えないNode.js Native ESMへの移行

はじめに

こんにちは。フロントエンド開発センター(UIT) Front-end Dev.9チームの鴻巣(@kazushikonosu)です。LINEスキマニおよびLINE Creators Marketのフロントエンド開発を担当しています。

LINEスキマニのフロントエンドチームでは、React/TypeScript製のWebアプリを開発しています。主にクライアントサイド向けのコードを扱っていますが、SSRのため同じリポジトリ内でNode.jsを使って実行されるTypeScriptコードも扱っています。クライアントサイドのモジュールバンドラとして長らくwebpackが使われていましたが、webpackを使い続けることでチームのパフォーマンスに影響する二つの問題に直面していました。

ひとつめは、開発環境におけるビルドパフォーマンスの問題です。webpackが差分が発生するたびに全体のビルドとバンドルを行うアプローチを採用している以上、開発時のビルド時間は遅くなってしまいます。ふたつめは、ビルド環境の設定のメンテナンスコストの問題です。さまざまな要件に対応するため、これまでの開発を通して長い webpack.config.js が生まれました。webpackの設定のほかBabelの設定のメンテナンスしていると、時代や要件の変化に合わせて設定を更新していくことが難しく、メンテナンスが負担になっていました。

Viteへの移行によってこの課題感を解消することができました。webpackと異なり、開発環境ではViteは変更のたびにバンドルを生成せず、差分があるファイルのみをブラウザで扱える形に変換します。またViteは標準で実用的(日本語のドキュメントには「実用的」と訳されていましたが、原文の "sensible" という言葉がより正確に意味をとらえていると思います)な設定を含んでいるため、デフォルトの設定を大きく変更することなく使うことが可能です。Babelの設定もViteのReactプラグインによって抽象化されているため、設定をアップデートしたり、メンテナンスするための負担を大幅に減らすことができます。

クライアント向けのコードについては重厚になってしまったビルドプロセスを整理して高速化することできました。ひるがえってNode.jsで実行されるサーバサイドのコードに目を向けると、クライアント向けのコードと同じような問題を抱えていました。

TypeScriptのソースコードにはES Modules構文が使われていましたが、Node.jsで実行される前にBabelを使ってCommonJS形式のモジュールにトランスパイルされます。また、クライアント向けの設定とは別に、サーバサイド向けのBabelの設定がメンテナンスされていました。開発の際は変更のたびにバンドルこそ行われていませんが、都度トランスパイルが行われます。クライアントバンドルのツールチェインと同様に、サーバサイドについてもプロセスをスリム化するため、Native ESMに移行しました。Native ESMに移行するため、アプリケーションコードを変更するとレビューの負担が増え、移行に要する時間が長くなります。この記事では、アプリケーションコードをなるべく変更せずにミニマルな形でNative ESMに移行するための戦略について解説します。

Native ESMへの移行の必要性

ES Modules (ESM)とは、JavaScriptでモジュールを扱うために考案された標準の仕様です。ブラウザはES Modulesを <script type="module">タグで扱えるようになったほか、ブラウザ以外のJavaScriptランタイムでもES Modulesのサポートが進んでいます。Node.jsでは初期からモジュールを扱うための仕様としてCommonJSが存在しましたが、Node.js v12よりES Modulesの実行がサポートされるようになりました。ES Modulesのままコードを実行することをNative ESMと呼びますが、Node.js環境におけるNative ESMにフォーカスするとき「Node.js Native ESM」として言及されます。

Node.jsにはCommonJSのパッケージとESMのパッケージを相互に参照する (interopする) ための仕組みが存在しますが、一部に制約があります。ESMのパッケージからはCommonJSのモジュールをimportすることができるのに対して、CommonJSのパッケージからはES Moduleをrequire()することができません。TypeScriptファイル内ではES Moduleの構文でimportするコードを書いていたとしても、Node.jsで実行するためにトランスパイルされたコードがCommonJS形式(たとえばtsconfig.jsonの "compilerOptions.module" フィールドで "CommonJS" を指定してtscを使っている場合など)であると、ES Module形式のパッケージのimportでエラーが発生します。Native ESMのみのnpmパッケージ (Pure ESMと呼ばれます)が登場しており今後も増えることが予想されるため、Native ESMへの移行はいずれ必要になります。

CommonJSにおけるrequire()と異なり、ES Modulesのimportは構文として提供されているため静的解析が容易です。 WebpackやViteといったモジュールバンドラはこの特徴を生かして、 ES ModulesについてTree Shakingの機能を提供しています。また ViteのDev Serverでは、バンドルを行わずにそれぞれのnpmパッケージとソースファイルに対応するブラウザで実行可能なES Moduleを配信することによってビルド時間を高速化しています。CommonJSと比べてメリットのあるES Modulesへの移行が進み、ES Modulesを前提としたツールが普及するなかでコードベースの将来への対応性を高めるためにNative ESMへの移行がベターであると考えられます。

Faux ESMの問題

ブラウザ向けのJavaScriptコードベースでもモジュールを扱えるように、モジュールバンドラはES Module構文を解釈してバンドルを生成する仕組みを提供しています。しかし、一部でES Moduleの仕様とは異なるモジュール解決の実装がされているため、ES Moduleに対応したランタイムで直接コードを実行することはできません。ES Moduleのシンタックスを利用しているが、ES Moduleの仕様に準拠してモジュールの解決ができず、モジュールバンドラやトランスパイラが必要になる記法は「Faux ESM」(あるいは Fake ESMなど)と呼ばれます。

具体的には、import文における拡張子の省略があげられます。CommonJSにおいては、Node.jsのresolveアルゴリズムによって、require()で拡張子を省略しても解決されましたが、ES Modulesの仕様では、拡張子の指定が必須になります。また、ファイル間でimportするとき、相対パスで ../../../../のような指定を行わずに済むように、パスのエイリアスを設定する場合がありますが、これはES Modulesの仕様ではなくモジュールバンドラ固有の機能です。TypeScriptを使っている場合 tsconfig.jsonの "compilerOptions.paths" や "compilerOptions.baseUrl" でパスのエイリアスを設定することが可能ですが、あくまで型の上でモジュール解決を行うようになるだけであり、tscが生成するコードには影響が及びません。モジュールバンドラやトランスパイラにパスのエイリアスの解決は委ねられています。

同様に、Node.jsで実行されるコードについてもFaux ESMの問題が存在します。拡張子が省略されていたり、パスのエイリアスが使われているimport文が存在する場合、tsconfig.jsonの "compilerOptions.module" フィールドで "CommonJS" の指定を "ESNext" に変更するだけでは実行できず、tscのほかになんらかの変換プロセスが必要になります。

移行の戦略

これまでサーバーサイドのTypeScriptコードを実行する際には、Babelを使用してCommonJSに変換していました。またパスのエイリアスを解決するためにbabel-plugin-module-resolverプラグインを利用していました。開発環境ではbabel-nodeを使いインメモリでBabelによるトランスパイルを行い、サーバを実行していました。プロダクション環境では、デプロイステップでBabel変換後のコードを生成して、これを実行していました。

ソースコードに手を加えて移行を進めると、移行のプロセスの負担が大きくなります。アプリケーションコードになるべく手を加えずにNative ESM対応を行うため、Node.js Loaders APIとTypeScript Transformer APIを活用します。

開発環境では、Node.jsのLoaders APIを使用するCustom ESM Loaderを実装しました。Node.jsのLoaders APIは、実行時にモジュール解決の振る舞いを変更する機能を提供しています。Custom ESM LoaderをNode.jsサーバを立ち上げる際に読み込むことで、Faux ESMで書かれたimport文を解決できるようになります。開発時にはコードを変更してサーバを再起動する状況が頻繁に発生しますが、Babelなどコードの変換を行なって実行する場合と違って、ランタイムでモジュールの解決に手を加えているため、パフォーマンスの利点があります。

プロダクション環境では、Babelによる変換プロセスをなくし、tsc (TypeScriptコンパイラ) によるトランスパイルに一本化しました。tscでトランスパイルする際に、TypeScript Transformer APIをつかったCustom Transformerでimport文を変換しNative ESMとして実行可能なJavaScriptコードを生成します。

この方法を使うことでアプリケーションコードに変更を加えないことで移行の負担を減らすことと、スリムなビルド環境を構築することを両立することができます。

リポジトリ構成

新たに実装したCustom ESM LoaderおよびCustom Transfomerの前提となる、LINEスキマニのリポジトリの構成を紹介します。

├── src/
│   ├── client/ # クライアント向けコード
│   │   └── entry.js
│   └── server/ # SSRサーバのコード
│       └── entry.js
├── tools/
│   └── build.ts
# プロダクションビルド生成のスクリプト。
Custom Transfomerを含む ├── loader.js # Custom ESM Loader ├── package.json └── tsconfig.json

tsconfig.jsonの"compilerOptions.baseUrl"フィールドには"./src"を指定しています。そのため、たとえば ./src/server/a/b/c.tsというファイルであれば次のようにimportすることが可能です。

import c from 'server/a/b/c';

Custom ESM loaderの実装

開発環境ではランタイムでCustom ESM loaderを使い、Faux ESMで記述されたimport文を解決しています。Custom ESM Loaderは次の処理を行います。

  • TypeScriptコードの変換
  • パスエイリアスの相対パスへの変換
  • import文の解決には従来のNode.jsのモジュール解決アルゴリズムを使う。 .ts および .tsx  拡張子は .js に置き換える

https://github.com/nodejs/loaders-test の例をもとに、この要件を満たす ./loader.js を実装しました。ts-node は ts-node/esm  Custom ESM Loaderを提供しているので、これを拡張しました。Node.jsには "--experimental-specifier-resolution=node" オプションがあり、従来のNode.jsのモジュール解決のアルゴリズムを使う方法を提供していますが、パスエイリアスと組み合わせると動作しないため、loader内で処理を行なっています。

import { isBuiltin } from 'node:module';
import { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import {
  resolve as resolveTs,
  getFormat,
  transformSource,
  load,
} from 'ts-node/esm';
import * as path from 'node:path';

import resolveCallback from 'resolve/async.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const resolveAsync = promisify(resolveCallback);
const baseURL = pathToFileURL(`${__dirname}/`).href;

export async function resolve(specifier, context, next) {
  const { parentURL = baseURL } = context;

  if (isBuiltin(specifier)) {
    return next(specifier, context);
  }

  // `resolveAsync` works with paths, not URLs
  if (specifier.startsWith('file://')) {
    specifier = fileURLToPath(specifier);
  }
  const parentPath = fileURLToPath(parentURL);

  if (specifier.startsWith('server')) {
    specifier = path.resolve(__dirname, 'src', specifier);
  }

  let url;
  try {
    const resolution = await resolveAsync(specifier, {
      basedir: dirname(parentPath),
      extensions: ['.js', '.ts', '.tsx'],
    });
    url = pathToFileURL(resolution).href;
    url = url.replace(/\.(ts|tsx)$/, '.js');
  } catch (error) {
    if (error.code === 'MODULE_NOT_FOUND') {
      // Match Node's error code
      error.code = 'ERR_MODULE_NOT_FOUND';
    }
    throw error;
  }

  return resolveTs(url, context);
}

export { getFormat, transformSource, load };

使用する際には、--loader optionを指定することでサーバを立ち上げることができます。

node --loader ./loader.js src/server/index.ts

TypeScript Custom Transformerの実装

プロダクション環境では、あらかじめNative ESMとして実行可能になるようにソースコードを変換しています。TypeScript Transfomer APIを使ったCustom Transfomerはtscコマンドから使用することができません。そのため、TypeScriptのNode.js APIを呼び出してTypeScriptのトランスパイルとCustom Transfomerによる変換を行なっています。

  • パスエイリアスの相対パスへの変換
  • import文の解決には従来のNode.jsのモジュール解決アルゴリズムを使う。 .ts および .tsx  拡張子は .js に置き換える

./tools/build.tsはViteによるクライアントバンドルの生成など複数のプロセスを担っていますが、そのなかでサーバサイドTypeScriptのNative ESMへの変換を行なっている部分を抽出しました。./tsconfig.server.jsonを別途用意して、"compilerOptions.module"フィールドには "ESNext" を指定する必要があります。

import { fileURLToPath } from 'node:url';
import { isBuiltin } from 'node:module';
import ts from 'typescript';
import resolve from 'resolve';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.resolve(__dirname, '../tsconfig.server.json');

const { config } = ts.readConfigFile(configPath, ts.sys.readFile);

const { options, fileNames } = ts.parseJsonConfigFileContent(
  config,
  ts.sys,
  process.cwd(),
);

const program = ts.createProgram({ options, rootNames: fileNames });

const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
  return (sourceFile) => {
    const visitor = (node: ts.Node): ts.Node | undefined => {
      if (
        ts.isImportDeclaration(node) &&
        ts.isStringLiteral(node.moduleSpecifier)
      ) {
        const moduleSpecifierText = node.moduleSpecifier.text;

        let modifiedModuleSpecifierText;

        if (moduleSpecifierText.startsWith('server')) {
          const relativePath = path.relative(
            path.dirname(sourceFile.fileName),
            path.resolve(__dirname, '../src', moduleSpecifierText),
          );

          modifiedModuleSpecifierText = relativePath.startsWith('../')
            ? relativePath
            : `./${relativePath}`;
        }

        if (!isBuiltin(moduleSpecifierText)) {
          const resolution = resolve.sync(
            modifiedModuleSpecifierText || moduleSpecifierText,
            {
              basedir: path.dirname(sourceFile.fileName),
              extensions: ['.js', '.ts', '.tsx'],
            },
          );

          if (!resolution.includes('node_modules')) {
            const relativePath = path
              .relative(path.dirname(sourceFile.fileName), resolution)
              .replace(/\.(ts|tsx)$/, '.js');

            modifiedModuleSpecifierText = relativePath.startsWith('../')
              ? relativePath
              : `./${relativePath}`;
          }
        }

        if (modifiedModuleSpecifierText) {
          return context.factory.createImportDeclaration(
            node.modifiers,
            node.importClause,
            context.factory.createStringLiteral(modifiedModuleSpecifierText),
            node.assertClause,
          );
        }
      }

      return ts.visitEachChild(node, visitor, context);
    };

    return ts.visitNode(sourceFile, visitor);
  };
};

program.emit(undefined, undefined, undefined, undefined, {
  after: [transformer],
});

さいごに

Node.jsのCustom ESM LoaderとTypeScript Custom Transfomerを実装することによって、少ない負担でビルド環境をスリム化し、Node.js Native ESMに移行することができます。Native ESMへの移行によって、将来への対応性を高めることができただけでなく、従来のツールチェインを使い続けることによって発生する問題を解消することができました。Viteのプロジェクトを初期化すると、package.jsonの"type"フィールドには"module"が指定されます。モジュールバンドラをViteに移行するタイミングで、併せてサーバサイドのNative ESM移行にチャレンジしてみるのはいかがでしょうか。