🎼

Remixをより便利に 【大規模ソフトウェアを手探る】

2023/11/12に公開

はじめに

この記事は、東京大学工学部電気電子工学科・電子情報工学科の後期実験、「大規模ソフトウェアを手探る」の成果報告レポートです。

世の中には、オープンソースソフトウェア(以下、OSS)という誰でも無料で使えて、その中身を自由に見たり改良したりできるソフトウェアが多数存在します。
当実験は、OSSへの機能追加を通じて、全容を把握するのが困難な大規模ソフトウェアへの対処方法を学ぶことを目的としています。

Remixとは

Remixとは、Reactをベースとした、モダンなアプリケーションを開発するためのフルスタックフレームワークです。

2021年にv1が公開され、2023年にv2が公開された比較的新しいフレームワークであり、先発のNext.jsなどのフレームワークと比べてビルドやコンテンツの提供が高速であるという特徴を持っています。

やったこと

1. ビルド時の表示改善

remix buildでアプリをビルドすると、以下のような出力が得られます。

改善前の出力

他方、Next.jsを用いてビルドをした際には、以下のように表示されます。

Next.jsの出力

これらを見比べると分かるように、Remixではビルドの完了報告しかしてくれない一方、Next.jsではビルドしたファイルの詳細を教えてくれます。

ビルドしたファイルの詳細が分かると、様々なメリットがあります。
例えば、あまりにもサイズが大きいファイルがあると、アプリのパファーマンスが落ちてしまいます。
ビルドした際に、そのようなファイルの存在に気付くことができれば、ファイルを分割するなどによって、パフォーマンスを改善できます。

このことを受け、Remixでビルドを行った際にも詳細が分かるように改善を行いました。
その結果、remix build --statsのように--statsオプションをつけてビルドを実行すると、以下のような出力されるようになりました。

改善後の出力

Next.jsの出力に勝るとも劣らない、詳細な表示が得られます。

2. ファイル名によるエラー処理の判定

2つ目の改良点は、Error Boundaryを記載したファイルをファイル名で判別し、一つ一つインポートしなくても実行を可能にする、というものです。

Remixはツリー状に構成されたディレクトリとファイルの位置関係をもとにパスを構成します。この時、あるファイルをページの外枠としてツリーの深度の深いファイルをすべてそのページのコンポーネントとして利用することができ、結果複数ファイルを用いてヒエラルキーの分かりやすいページ作成が可能になります。この時、あるコンポーネント内でのみエラーが生じた際にそのエラーをファイル内で検知し、エラー処理を実行するのがError Boundaryです。

Error Boundaryの役割について具体例で説明します。

下のようなページ構成を考えます。
このページは、外枠となるroot要素とその内側のhoge要素から成り立っています。

ここで、hoge要素の中で何かしらのエラーが生じたとします。
このとき、Error Boundaryが設定されていないと、下の画像のように表示されます。
全体がエラーとなって、ページが完全に壊れています。

一方で、Error Boundaryが適切に設定されていると、以下のようになります。
エラーが生じたhoge要素のみがエラー表示され、その外側のroot要素は問題なく表示されています。

このように、Error Boundaryを用いると、エラー処理をエラーが起きた要素内で完結させることができます。
これにより、エラーが生じてもそれ以外の場所は正常に動くので、アプリケーションの信頼性が向上します。
また、開発段階でもエラーの箇所が分かりやすくなり、修正や再発防止が容易になります。

本来はファイル内に記載をしておくものですが、前述のパス構成方法を利用し、'error'という名前を識別してError Boundaryモジュールをバンドルできるよう実装を目指しました。

ビルドの方法

1. 事前準備

Remixのビルドには、yarnというパッケージ管理ツールが必要です。
インストールされていない場合、yarn公式のインストール方法に従ってインストールしてください。

2.ソースコードのダウンロード

Remixのソースコードは、公式リポジトリに公開されています。
リポジトリ内のremixというディレクトリをフォークし、それをローカル環境にクローンすると、手元の環境でソースコードを編集出来るようになります。

3. 必要パッケージのインストール

remixディレクトリの直下でyarn installを実行します。
そうすると、package.jsonに記載されている、ビルドに必要なモジュールがnode_modules下にインストールされます。

$ yarn install

4.ビルド

remixディレクトリ内でyarn buildを実行すると、build/node_modules下にソースコードからremixがビルドされます。

$ yarn build
エラーでビルドができなかった場合
[!] (plugin copy-to-remix-playground) Error ......

上記のようなエラーが出た場合は、remix直下にplaygroundというディレクトリを作り、再びyarn buildを実行すればビルドが成功します。

mkdir playground
yarn build
補足: エラーの原因

このエラーは公式から直接クローンした場合は起きず、一度違うリポジトリにプッシュして再度クローンすると発生するものです。

playgroundディレクトリは.gitignoreファイルに記載されています。
そのため、一度違うリポジトリにプッシュすると、そのリポジトリからはplaygroundディレクトリは無くなります。

ビルドしたRemixを使う方法

前節では、ローカル環境にRemixをビルドしました。
しかし、このままRemixのプロジェクトを作成し、アプリを実行してもローカルのRemixは使われません。

例として、以下のようなディレクトリ構成を考えます。
remixは、前節でビルドしたRemixのディレクトリです。
また、my-remix-app公式チュートリアルに沿って作られたディレクトリで、webアプリを構成しています。

.
├── my-remix-app
│   ├── README.md
│   ├── app
│   │   ├── app.css
│   │   ├── data.ts
│   │   └── root.tsx
│   ├── package.json
│   ├── public
│   │   └── favicon.ico
│   ├── remix.config.js
│   ├── remix.env.d.ts
│   └── tsconfig.json
└── remix // remix下の構成は省略

公式チュートリアルに沿って、my-remix-app内でnpm installnpm run devを実行すると、確かにサーバーが起動します。しかし、この時に使われるのはローカルのRemixではなく、オンライン上のRemixです。

ローカルのRemixを使いたい場合には、my-remix-app/package.jsonの中身を一部書き換える必要があります。

具体的には、元は以下のようになっている記述を、

  "dependencies": {
    "@remix-run/node": "^2",
    "@remix-run/react": "^2",
    "@remix-run/serve": "^2",
    "isbot": "^3.6.13",
    "match-sorter": "^6.3.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "sort-by": "^1.2.0",
    "tiny-invariant": "^1.3.1"
  },
  "devDependencies": {
    "@remix-run/dev": "^2",
    "@remix-run/eslint-config": "^2",
    "@types/react": "^18.2.20",
    "@types/react-dom": "^18.2.7",
    "eslint": "^8.47.0",
    "typescript": "^5.1.6"
  },

以下のように書き換える必要があります。

  "dependencies": {
    "@remix-run/node": "file:../remix/build/node_modules/@remix-run/node",
    "@remix-run/react": "file:../remix/build/node_modules/@remix-run/react",
    "@remix-run/serve": "file:../remix/build/node_modules/@remix-run/serve",
    "isbot": "^3.6.13",
    "match-sorter": "^6.3.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "sort-by": "^1.2.0",
    "tiny-invariant": "^1.3.1"
  },
  "devDependencies": {
    "@remix-run/dev": "file:../remix/build/node_modules/@remix-run/dev",
    "@remix-run/eslint-config": "^2",
    "@types/react": "^18.2.20",
    "@types/react-dom": "^18.2.7",
    "eslint": "^8.47.0",
    "typescript": "^5.1.6"
  },

この書き換えについて、詳しく説明していきます。
package.json"dependencies"及び"devDependencies"の中身には、アプリ実行や開発に必要なモジュールの名前とバージョンが書かれています。
書き換え前の、

    "@remix-run/node": "^2",

という箇所は、@remix-run/nodeというモジュールのバージョン^2を使うという意味になります。

補足: "^" の意味

nodeのパッケージのバージョンは、5.1.6のように . で分けられた3つの数字から成ります。

一番左側の数字は、メジャーのバージョンを表します。
この数字が違うとモジュールの互換性が保証されておらず、使う側のコードを書き換える必要があります。
一方で、真ん中の数字はマイナーのバージョン(新機能の追加)、右の数字はパッチのバージョン(バグの修正)をそれぞれ表しており、モジュールの呼び出し側のコードを変更する必要はありません。

バージョンの数字の先頭に ^ を付けると、
「メジャーは一致し、マイナーとパッチは指定されたもの以上」という意味になります。
例えば、^5.1.6と指定すると、6.0.15.4.4といったバージョンはマッチし、4.5.65.0.3といったバージョンはマッチしなくなります。

要するに、^ をつけてバージョンの指定をすると、 呼び出し側のコードを変更しない範囲で最も新しいバージョンがインストールされるのです。

一方で、書き換えたあとの

    "@remix-run/node": "file:../remix/build/node_modules/@remix-run/node",

という箇所では、バージョンの代わりにファイルのパスが指定されています。
このパスの場所がどうなっているか見ていきましょう。

remix/node_modules/@remix-run以下の構成は、以下の通りになっています。

remix/build/node_modules/@remix-run
├── architect
├── cloudflare
├── cloudflare-pages
├── cloudflare-workers
├── css-bundle
├── deno
├── dev
├── express
├── node
├── react
├── serve
├── server-runtime
└── testing

確かに、remix/node_modules/@remix-run/nodeが存在しています。
また、他の書き換え箇所に相当するものも存在することが分かります。

モジュールのバージョンの代わりに、ローカルのパスを指定すると、yarn installをする際に、オンライン上のものでなく、指定されたディレクトリをモジュールとしてインストールします。

以上のことから、package.jsonにおける@remix-run/...というモジュールのバージョン指定を、対応するremix/node_modules/@remix-run/...というディレクトリへのパスに書き換えて、yarn install及びyarn buildを実行すると、ローカルのRemixを使ってアプリを実行できます。

実装の詳細

1. ビルド時の表示改善

実装方針を立てる

今までRemixでビルドされたファイルの詳細情報を得る方法としては、ビルド時に出力されたmetafileと呼ばれるファイルをesbuildのアナライザーを用いて分析する方法がありました。

このmetafileを出力している部分を突き止めれば、metafileの情報を用いてビルドの情報を表示できると考えました。

ビルドの流れを追う

まず、remix build 時にどのような処理が行われるかを確認するため、VSCodeのデバッガを用いました。

remix-example-appのディレクトリに.vscode/lauch.jsonを作成し、以下のように編集します。(参考: VSCode)

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "skipFiles": [
        "<node_internals>/**"
      ],
      "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/remix",
      "args": ["build"]
    }
  ]
}

重要なのは runtimeExecutableargs で、前者でremixコマンドの実行ファイルを、後者でコマンドの引数を設定します。

サイドバーから "Run and Debug" を選択し、Runボタンを押すとデバッグが始まります。

適当な位置にブレークポイントを設定し、コードの実行の流れを追ったところ、remix build を実行すると@remix-run/dev/dist/cli/run.jsでコマンドやオプションが解析され、同じディレクトリにあるcommands.js内のbuild関数が呼ばれる事がわかりました。

さらに、build関数内で@remix-run/dev/dist/compiler/compiler.js内のcompile関数が呼ばれ、その中で同じcompilerディレクトリ下のcss/compiler.js, js/compiler.js, server/compiler.js内のcompile関数がサブコンパイラとして呼び出されることがわかりました。

そして、各サブコンパイラのcompile関数内でesbuildを用いてファイルをバンドルし、バンドル後のファイルやmetafileなどを得、compiler/analysis.js内のwriteMetafile関数を用いてmetafileを書き出していることがわかりました。また、esbuildがバンドルしたファイルはesbuildではなくRemix側でファイルシステムに書き込んでいることもわかりました。

ファイルの情報を表示する

以上から、metafileを受け取りビルドの情報を出力する関数をanalysis.js内に追加し、それを各サブコンパイラ内で呼び出すことにしました。

metafileには入力・出力ファイルのそれぞれのファイル名やサイズなどの情報が含まれており(参考: esbuildのAPIリファレンス)、出力されたファイルの情報を順に表示することでバンドルされたファイル名とサイズを出力することができました。

また、commands.js内のbuild関数に引数を追加することで、この機能を--statsオプションを付けたときにのみ実行されるようにできました。

出力を装飾する

出力を装飾したかったのでRemix内でどのようにテキストを装飾しているか調査したところ、@remix-run/dev/dist/colors.js内に装飾用の関数があることがわかりました。また、行頭を揃えるためにpadEndメソッドを使うと良いこともわかりました。(参考: MDN)

padEndメソッドを用いて全行の行頭を揃えるには各列で最も長い要素の長さが必要です。しかし、サブコンパイラ内で別々に関数を呼び出していると、長さの情報を共有できません。そこで、各サブコンパイラ内ではなくcompiler/compiler.js内で関数を呼ぶことで全体の行頭を揃える事ができるようになりました。

First load JSを表示する

これでバンドル後のファイル名とそのサイズを出力することはできたのですが、実際に興味があるのはページを閲覧したときにネットワークを通じて送られるJSファイル(First load JS)のサイズです。

普通、サーバーからはgzipなどを用いて圧縮されたファイルが送られるため、圧縮後のサイズを表示したいと考えました。(参考: MDN)

しかし、metafileにはファイルの内容に関する情報が含まれていないため、metafileではなくバンドルされたファイルそのものを入力にとり、圧縮をnode:zlibで行って圧縮後のサイズを得るようにしました。

さらに、各パスにアクセスしたときに読み込まれるファイルサイズの合計を求めるため、新たにmanifestと呼ばれるデータを用いることにしました。manifestには各ルートの親となるルートやインポートしているファイル、マッチするパスなどの情報が含まれており、あるパスにマッチするルートの親を再帰的にたどり、インポートしているファイルを集計することでそのパスにアクセスしたときに読み込まれるjsファイルのサイズを求めることができます。

実際に読み込まれるJSファイルにはこれ以外にもアプリ全体で必要なファイルなどがあり、それらはmanifestのentryプロパティから得られます。

これらのデータを各パスに対して計算することで、First load JSを表示することができました。

バグを解消する

esbuildがバンドルしなかった静的ファイル (画像ファイルや他のライブラリによって生成されたファイルなど) がクライアント側とサーバー側の両方で表示されるバグがありました。

作成した関数が使っているデータはRemixが実際にファイルシステムに書き込むのに使っているデータと同じなので、Remixはバンドルされたファイルを書き込む際に何らかの処理を行ってこの重複を解決していると考えました。

Remixがサーバー側のファイルを書き込むのに使っている関数はcompiler/server/write.js内のwrite関数で、ここに不要なファイルをフィルタリングする処理が書かれていたので、これと同様のロジックを用いて出力するファイルをフィルタし、バグを解消しました。

表示を充実させる

表示をより見やすくするため、_share_assetsなどのディレクトリで出力ファイルをまとめ、名前でソートするようにしました。

さらに、manifestには各ルートにloaderactionがあるかどうかのデータも含まれていたのでこれも出力に使用することにしました。

また、各パスのFirst load JSとサーバー側のファイルのサイズに応じて色分けをするようにしました。ファイルサイズが大きくなると文字色が緑→黄色→赤と変化するため、パフォーマンスを意識しやすくなります。

analysis.ts への追加
type FileInfo = { name: string; size: number };
let fileKinds = ["client", "server"] as const;
export type FileKind = typeof fileKinds[number];

let sizeMemo: Record<string, number> = {};

let prettyPrintSize = (
  size: number,
  color: boolean = false,
  kind: FileKind = "client"
) => {
  let colorFn = (x: string) => x;
  let thresholds =
    kind === "client" ? [130_000, 170_000] : [800_000, 1_500_000];
  if (color) {
    if (size < thresholds[0]) {
      colorFn = colors.ok;
    } else if (size < thresholds[1]) {
      colorFn = colors.warning;
    } else {
      colorFn = colors.error;
    }
  }
  if (size >= 1000) {
    return colorFn(`${(size / 1000).toFixed(1)} kB`);
  } else if (size >= 1000_000) {
    return colorFn(`${(size / 1000_000).toFixed(1)} MB`);
  }
  return colorFn(`${size} B`);
};

let processOutputFiles = async (
  outputFiles: OutputFile[],
  buildDir: string,
  routeFileNames: string[]
) => {
  let compress = promisify(gzip);

  let compressPromises: Promise<FileInfo>[] = [];

  for (let outputFile of outputFiles) {
    let fileName = path.relative(buildDir, outputFile.path);
    let compressPromise = compress(outputFile.contents);

    compressPromises.push(
      compressPromise.then((compressed) => ({
        name: fileName,
        size: compressed.length,
      }))
    );
  }

  let files = await Promise.all(compressPromises);
  let dirs: Record<string, FileInfo[]> = {};
  let restFiles: FileInfo[] = [];

  for (let file of files) {
    let idx = file.name.indexOf("/");
    if (idx === -1) {
      restFiles.push(file);
      continue;
    }
    let dir = file.name.slice(0, idx + 1);
    let fileName = file.name.slice(idx + 1);
    (dirs[dir] ??= []).push({ name: fileName, size: file.size });
  }

  let notRouteFiles = [
    ...restFiles.flatMap(({ name }) =>
      routeFileNames.includes(name) ? [] : name.length + 1
    ),
    ...Object.values(dirs)
      .flat(2)
      .flatMap(({ name }) =>
        routeFileNames.includes(name) ? [] : name.length + 4
      ),
  ];

  let maxFileNameLength = Math.max(...notRouteFiles);
  let maxFileSizeLength = Math.max(
    ...restFiles.map((file) => prettyPrintSize(file.size).length),
    ...Object.values(dirs)
      .flat(2)
      .map(({ size }) => prettyPrintSize(size).length)
  );

  return {
    restFiles,
    dirs,
    maxFileNameLength,
    maxFileSizeLength,
  };
};

let displayProcessedFile = (
  maxFileNameLength: number,
  maxFileSizeLength: number,
  sharedSize: number,
  kind: FileKind,
  restFiles: FileInfo[],
  dirs: Record<string, FileInfo[]>,
  routes: Manifest["routes"],
  clientFiles: FileInfo[],
  routeFileNames: string[],
  hint: string
) => {
  if (restFiles.length === 0 && Object.keys(dirs).length === 0) return;

  console.log(colors.bgWhite(` ${colors.gray(kind)} `));

  if (kind === "client") {
    displayRoutes(
      routes,
      clientFiles,
      sharedSize,
      maxFileNameLength,
      maxFileSizeLength
    );
  }

  for (let dir of Object.keys(dirs)) {
    let notRouteFiles =
      kind === "client"
        ? dirs[dir].filter((file) => !routeFileNames.includes(file.name))
        : dirs[dir];

    if (notRouteFiles.length === 0) {
      continue;
    }

    console.log(` ${dir}`);
    for (let i = 0; i < notRouteFiles.length; i++) {
      let treeSymbol = i === notRouteFiles.length - 1 ? "└─" : "├─";
      let fileName = `${treeSymbol} ${notRouteFiles[i].name}`;
      let fileSize = notRouteFiles[i].size;
      let fileNameDisplay = ` ${fileName}`;

      console.log(
        `${fileNameDisplay.padEnd(maxFileNameLength)}   ${prettyPrintSize(
          fileSize
        )}`
      );
    }
  }

  for (let file of restFiles.filter(
    (file) => !routeFileNames.includes(file.name)
  )) {
    let fileNameDisplay = ` ${file.name}`;

    console.log(
      `${fileNameDisplay.padEnd(maxFileNameLength)}   ${prettyPrintSize(
        file.size,
        kind === "server",
        kind
      )}`
    );
  }

  if (kind === "client") {
    let hintLengthOffset = colors.hint(hint).length - hint.length;
    let hintDisplay = ` ${colors.hint(hint)}`;

    console.log(
      `\n${hintDisplay.padEnd(
        maxFileNameLength + hintLengthOffset
      )}   ${prettyPrintSize(sharedSize, true)}`
    );
  }
  console.log();
};

let displayRoutes = (
  routes: Manifest["routes"],
  fileInfos: FileInfo[],
  sharedSize: number,
  maxFileNameLength: number,
  maxFileSizeLength: number
) => {
  let sortedRoutes = Object.values(routes).sort((a, b) => {
    let apath = a.path ?? "";
    let bpath = b.path ?? "";
    return apath.localeCompare(bpath);
  });

  for (let i = 0; i < sortedRoutes.length; i++) {
    let routeInfo = sortedRoutes[i];
    let path = routeInfo.path;
    if (path === undefined && routeInfo.index) {
      path = "";
    }
    if (path === undefined || routeInfo.id === "root") continue;

    if (!path.startsWith("/")) {
      path = `/${path}`;
    }

    let treeSymbol = i === sortedRoutes.length - 1 ? "└" : "├";
    let loaderSymbol = routeInfo.hasLoader ? "↓" : " ";
    let actionSymbol = routeInfo.hasAction ? "↑" : " ";

    let firstLoadSize =
      sharedSize +
      calcLoadSize(
        routes,
        fileInfos,
        routeInfo.id,
        new Set(routeInfo.imports),
        sizeMemo
      );
    let loadSizeDisplay = prettyPrintSize(sizeMemo[routeInfo.id]);

    let pathDisplay = `${treeSymbol} ${loaderSymbol}${actionSymbol} ${path}`;

    console.log(
      `${pathDisplay.padEnd(maxFileNameLength)}   ${loadSizeDisplay.padEnd(
        maxFileSizeLength
      )}   ${prettyPrintSize(firstLoadSize, true)}`
    );
  }
  console.log();
};

let filterAssets = (
  fileName: string,
  clientFiles: OutputFile[],
  buildDir: { assets: string; server: string }
) => {
  if ([".js", ".cjs", ".mjs"].some((ext) => fileName.endsWith(ext))) {
    return true;
  }
  if (fileName.endsWith(".map") && !fileName.endsWith(".css.map")) {
    return true;
  }
  let clientFileNames = clientFiles.map((file) =>
    path.relative(buildDir.assets, file.path)
  );
  if (!clientFileNames.includes(path.relative(buildDir.server, fileName))) {
    return true;
  }
  return false;
};

let findSize = (fileInfos: FileInfo[], fileName: string) => {
  return (
    fileInfos.find((file) => file.name === path.basename(fileName))?.size ?? 0
  );
};

let calcSharedSize = (
  entry: Manifest["entry"],
  rootName: string,
  fileInfos: FileInfo[]
) => {
  let sharedSize =
    findSize(fileInfos, entry.module) + findSize(fileInfos, rootName);
  for (let importName of entry.imports) {
    sharedSize += findSize(fileInfos, importName);
  }
  return sharedSize;
};

let calcLoadSize = (
  routes: Manifest["routes"],
  fileInfos: FileInfo[],
  route: string,
  imports: Set<string>,
  memo: Record<string, number>
): number => {
  if (memo[route] !== undefined) {
    return memo[route];
  }

  let routeInfo = routes[route];
  let routeSize = route === "root" ? 0 : findSize(fileInfos, routeInfo.module);

  if (routeInfo.parentId === undefined) {
    for (let importName of imports) {
      routeSize += findSize(fileInfos, importName);
    }
    memo[route] = routeSize;
    return memo[route];
  }

  if (routeInfo.imports) {
    for (let importName of routeInfo.imports) {
      imports.add(importName);
    }
  }

  memo[route] =
    routeSize +
    calcLoadSize(routes, fileInfos, routeInfo.parentId, imports, memo);

  return memo[route];
};

export let displayBuildInfo = async (
  ctx: Context,
  outputFiles: Record<FileKind, OutputFile[]>,
  manifest: Manifest
) => {
  let assetsBuildDir = ctx.config.assetsBuildDirectory;
  let serverBuildDir = path.dirname(ctx.config.serverBuildPath);

  let kinds = fileKinds;
  let hint = "First load JS (Shared)";

  let routeFileNames = Object.values(manifest.routes).flatMap((route) =>
    route.id !== "root" ? path.basename(route.module) : []
  );

  let processedFiles = await Promise.all([
    processOutputFiles(outputFiles.client, assetsBuildDir, routeFileNames),
    processOutputFiles(
      outputFiles.server.filter((file) =>
        filterAssets(file.path, outputFiles.client, {
          assets: assetsBuildDir,
          server: serverBuildDir,
        })
      ),
      serverBuildDir,
      routeFileNames
    ),
  ]);

  let headings = ["File", "Size(gzip)", "First load JS"];
  let headingLengthOffsets = headings.map(
    (heading) => colors.heading(heading).length - heading.length
  );

  let routePathLens = Object.values(manifest.routes).map((route) => {
    let path = route.path ?? "";
    let pathLen = path.length + 5;
    if (!path.startsWith("/")) {
      pathLen += 1;
    }
    return pathLen;
  });

  let maxFileNameLength = Math.max(
    ...processedFiles.map((file) => file.maxFileNameLength),
    ...routePathLens,
    headings[0].length,
    hint.length
  );
  let maxFileSizeLength = Math.max(
    ...processedFiles.map((file) => file.maxFileSizeLength),
    headings[1].length
  );

  let clientFiles = [
    ...processedFiles[0].restFiles,
    ...Object.values(processedFiles[0].dirs).flat(2),
  ].map((file) => ({
    name: path.basename(file.name),
    size: file.size,
  }));

  let sharedSize = calcSharedSize(
    manifest.entry,
    manifest.routes.root.module,
    clientFiles
  );

  console.log(
    `\n${colors
      .heading(headings[0])
      .padEnd(maxFileNameLength + headingLengthOffsets[0])}   ${colors
      .heading(headings[1])
      .padEnd(maxFileSizeLength + headingLengthOffsets[1])}   ${colors.heading(
      headings[2]
    )}`
  );
  processedFiles.forEach(({ restFiles, dirs }, idx) => {
    displayProcessedFile(
      maxFileNameLength,
      maxFileSizeLength,
      sharedSize,
      kinds[idx],
      restFiles,
      dirs,
      manifest.routes,
      clientFiles,
      routeFileNames,
      hint
    );
  });
};

2. ファイル名によるエラー処理の判定

方針1(失敗)

今回の改良作業はビルド時の表示改善のようにデバッガを使用できません。したがってエラー処理を行っている関数、変数は文字通り「手探り」で探さなければなりません。また、v2が公開されたのが比較的最近ということもあり、v1で使用した関数がファイル内の至る所に残存していました。こうしたアプリ実行時の実態追跡の困難さから改良箇所を探し当てるまでに相当な時間を要しました。

こうした苦心の果てに、packages/remix-react/routes.tsx内に以下の記述を見つけました。

export function createServerRoutes(
...
    let routeModule = routeModules[route.id];
    let dataRoute: DataRouteObject = {
      caseSensitive: route.caseSensitive,
      Component: routeModule.default,
      ErrorBoundary: routeModule.ErrorBoundary
        ? routeModule.ErrorBoundary
        : route.id === "root"
        ? () => <RemixRootDefaultErrorBoundary error={useRouteError()} />
        : undefined,

ここは表記にある通りサーバ側からファイルまでのルートを生成する際に使用される関数であり、クライアント側からも同じような関数により2重でルート構築が行われています。ここでroute.idに示されたファイルにおけるrouteModuleからErrorBoundaryを選択、バンドルすることで検証をし、さらにroute.idがページの外側を構成する'root'にある時は、コンポーネント状でエラーを検出しなかった時の予防策としてデフォルトでエラー処理を行う設定が施されています。この領域にroute.idに相当する部分をerror.tsxのものに書き換えればError Boundaryが作動すると考えました。

ところが、いざ実行してみるとエラー処理が行われず、(画像2)の通りが画面全体が壊れてしまいました。どうやら目的のファイル内にError Boundaryを設置しなかったことでError Boundaryによるファイル内走査が行われず、エラー自体が検出されなくなったようです。

方針2(成功?)

Remixでは、どこからも依存されていないファイルはバンドルに含められません。そのため、この機能を実装するためにはエラーファイルの内容をビルド時に読み込むか、ビルド時にファイル間に依存関係を追加し、esbuildにバンドルさせるかのいずれかが必要だと考えました。

前者の方法では変更箇所が多くなり、期間内に実装できないと考えたため、ファイルにエラーファイルの再エクスポート文を追加するesbuildのプラグインを作成する方針を選択しました。

エラーファイルが存在するか判定する

プラグインで再エクスポート文を追加する前に、そのルートに対応するエラーファイルが存在するかどうかを確認しておかないとエラーファイルが存在しなかった場合にビルドが失敗してしまいます。

エラーファイルの存在判定を行うため、Remixがフォルダルーティングの際にルートファイルをどのように判定しているか調べたところ、remix-dev/config/flat-routes.ts内のfindRouteModuleForFolder関数を用いている事がわかりました。

そこで、"error"という名前のファイルを探すfindErrorModuleForFolder関数を作成し、そのルートに対するエラーファイルの有無を表すerrorプロパティを、各ルートの情報を表すConfigRoute型に追加しました。

flat-routes.ts内のflatRoutes関数は上述した関数などを用いてrouteManifestを返します。これは各ルートに対しConfigRoute型のオブジェクトを格納したオブジェクトです。flatRoutes関数はremix-dev/config.ts内のreadConfig関数で呼び出され、このreadConfig関数はremix-dev/cli/commands.ts内のbuild関数内などで呼び出され、ルートの情報はconfigとしてアクセスできるようになります。

このconfigにはプラグインからもアクセスできるので、追加したerrorプロパティを用いて再エクスポート文を追加するかどうかを判定することができます。

プラグインでファイルの中身を変更する

次に、プラグインを使ってファイルの中身を変更します。まずブラウザ側のJSをコンパイルするときに使われるプラグインを記述しました。

remix-dev/compiler/js/plugins/routes.tsにルートとなるファイルを生成するためのプラグインがあり、そこのbuild.onLoad内に以下のような記述を追加しました。

if (route?.error) {
  contents = `export { ErrorBoundary } from "./${path.dirname(
    file
  )}/error.tsx";\n${contents}`;
}

これによってエラーファイルが存在するときのみErrorBoundaryの再エクスポート文を追加することができ、HMRによってページが更新されたときにErrorBoundaryが正しく反映されるようになりました。しかし、ページを再読込するとErrorBoundaryが認識されなくなりました。

ブラウザ側のバンドルファイルを確認すると正しくErrorBondaryが追加されていたため、これはサーバー側の問題であると考えました。そこで、サーバー側のJSをコンパイルするときにもファイルの中身を変更するようにしました。

remix-dev/compiler/server/plugins/addErrorExport.tsというファイルを新たに作成し、その中に以下のようなプラグインを作りました。

このプラグインはJSXまたはTSXのファイルに対し、エラーファイルが存在するか判定し、存在する場合にErrorBoundaryの再エクスポート文を追加するものです。

export function addErrorExportPlugin({ config }: Context): Plugin {
  return {
    name: "add-error-export",
    async setup(build) {
      let routesByFile: Map<string, Route> = Object.keys(config.routes).reduce(
        (map, key) => {
          let route = config.routes[key];
          map.set(route.file, route);
          return map;
        },
        new Map()
      );

      build.onResolve({ filter: /\.[jt]sx$/ }, (args) => {
        let file = args.path;
        let route = routesByFile.get(file.replace("./", ""));
        if (route?.error) {
          return {
            path: args.path,
            namespace: "add-error-export",
          };
        }
      });

      build.onLoad(
        { filter: /.*/, namespace: "add-error-export" },
        async (args) => {
          let file = args.path;

          let resolvedPath = path.resolve(config.appDirectory, file);
          let contents = await fs
            .ensureFile(resolvedPath)
            .then(() => fs.readFile(resolvedPath, "utf8"))
            .catch(() => "");

          let errorPath = path.resolve(
            config.appDirectory,
            file,
            "../error.tsx"
          );

          contents = `export { ErrorBoundary } from "${errorPath}";\n${contents}`;

          return {
            contents,
            resolveDir: config.appDirectory,
            loader: getLoaderForFile(file),
          };
        }
      );
    },
  };
}

このプラグインをコンパイラに入力することで、ErrorBoundaryが自動で読み込まれるようになりました。

改善すべき点

エラーファイルをファイル名から検出し、ErrorBondaryを自動で設定する機能として最低限のものは作ることができました。一方で、

  • ファイルを手動で再エクスポートするとプラグインによるものと衝突してしまう
  • エラーファイルでもルートファイルでもないファイルのパスが解決されない

等、重大な不具合もあることが分かりました。時間があればこれらの不具合も改善できればよかったと思います。

おわりに

今回の実験では、Remixの改善について一定の成果が得られました。
特に、ビルド時の表示の改善は、先発のNext.jsなどのフレームワークにあって、Remixにない機能を追加したという点で、非常に意義があったと思います。

また、実験の目標である、全容のつかめない大規模なソフトウェアへの取り組み方を学ぶ、という面でも収穫がありました。
デバッガを上手に使ったり、公式のドキュメントをしっかり読んだりなどの技術的な側面はもちろんですが、何よりも、世間で使われている立派なソフトウェアでも頑張ればなんとか挙動が分かりそう(?)という実感が得られたのが大きかったです。

この記事を読んでいる方が、Remixについて調べているうちに辿り着いたのか、当実験について興味をもって訪れたのかは分かりませんが、どちらにしろお役に立つことを祈って当記事を締めさせていただきます。

参考

大規模ソフトウェアを手探る

当実験の資料ページです。
デバッガの使い方をはじめ、大規模なソフトウェアの改良に取り組む上で参考になる資料が載っています。

Remixの公式ドキュメント

Remixを使った開発の仕方はもちろん、Remix自体に手を加える上で重要な情報も分かりやすくまとまっています。

Discussion