🦁

NestJSのビルドをswcを使って高速したかったが苦戦した話

2024/07/21に公開

はじめに

NestJS v10でSWCサポートが追加され、nest buildにオプションを渡すだけでSWCが利用できるようになりました。
詳細はこちら: NestJS v10リリース情報

しかし、特定の条件下でうまく動作しない問題に悩まされましたが、試行錯誤の末に解決することができましたので、共有します。

問題

前提

プロジェクト構成は以下の通りです

.
├── src/
│   └── hoge/
│       └── hoge.ts
├── tasks/
│   └── hogeTask.ts
├── prisma/
├── batch/
│   └── hogeBatch.ts
└── package.json

srcディレクトリの外に実行ファイルを配置しています。今回この構成が起因となって問題が発生しました。

nest-cli.json

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "builder": {
      "type": "swc"
    },
    "assets": [
      {
        "include": "./src/mail/emails/**/*.ejs",
        "outDir": "./dist",
        "watchAssets": true
      },
      {
        "include": "./src/mail/emails/**/*.css",
        "outDir": "./dist",
        "watchAssets": true
      }
    ]
  }
}

builderでswcを有効にしています。

1. ビルド結果のdistディレクトリの構成がおかしい

distの出力結果

.
├── hoge/
│   └── hoge.js
├── hogeTask.js
├── prisma/
├── hogeBatch.js
└── package.json

srcディレクトリが消え、srcの外に置いてあったディレクトリも全て展開されています。
静的ファイルをdistディレクトリにコピーしているため、実際のディレクトリ構成と同一でないと困ります(src以下に全てを入れれば問題は発生しません)。

2. watchオプションを有効にしても自動でコンパイルされない

この問題は1を解決した後に発生しましたが、nest-cli.jsonsourceRoot.に設定していると発生することがわかりました。詳しくは後述します。

解決策

解決策を先に示します。nest-cli.jsonを以下のようにすることで上述した問題が解消されました。

  "sourceRoot": ".",
  "entryFile": "./src/main",
  "compilerOptions": {
    "builder": {
      "type": "swc",
      "options": {
        "stripLeadingPaths": false,
        "includeDotfiles": true
      }
    }
  }

処理の流れ

NestJSでSWCオプションを有効にすると、大まかに以下の流れで処理が行われます:

  1. @nestjs/cli: nest-cli.jsonの設定を元に@swc/cliにビルドを指示
  2. @swc/cli: TypeScriptからJavaScriptへのビルドを実行

watchオプションを有効にした場合は以下が追加されます:

  1. @swc/cli: TypeScriptファイルの変更を検知し、ビルドを実行
  2. @swc/cli: dist/に結果が出力される
  3. @nestjs/cli: distの変更を検知し、サーバーを再起動

調査

「ビルド結果のdistディレクトリの構成がおかしい」の調査

@nestjs/cliを介してSWCのビルドが実行されると、swcDefaultsFactoryでデフォルトのコンフィグとユーザーが指定した設定をマージして@swc/cliに渡しています。

const swcOptions = swcDefaultsFactory(extras.tsOptions, configuration);
  // 省略
 await swcCli.default({
  ...options,
  swcOptions,
  cliOptions: {
   ...options.cliOptions,
   watch: extras.watch,
  },
});

このデフォルトの設定を見ると、stripLeadingPathstrueになっていました。これをfalseにすることで修正されました。

cliOptions: {
    outDir: tsOptions?.outDir ? convertPath(tsOptions.outDir) : 'dist',
    filenames: [configuration?.sourceRoot ?? 'src'],
    sync: false,
    extensions: ['.js', '.ts'],
    copyFiles: false,
    includeDotfiles: false,
    quiet: false,
    watch: false,
    stripLeadingPaths: true,
 ...builderOptions,
},

stripLeadingPaths

公式ドキュメントによると、

最終的な出力パスを構築する際に、先頭のディレクトリ(すべての親相対パスを含む)を削除します。例えば、srcフォルダ内のすべてのモジュールをフォルダを作成せずに出力します。

このオプションを有効にすると、すべてのディレクトリが展開されて出力されてしまうため、問題が発生していました。

「watchオプションを有効にしても自動でコンパイルされない」の調査

次に@nestjs/cliを見てみます。@swc/cliにビルドを指示する処理でwatchオプションが有効な場合はwatchFilesInOutDirを実行していることがわかりました。

public async run(
  configuration: Required<Configuration>,
  tsConfigPath: string,
  appName: string,
  extras: SwcCompilerExtras,
  onSuccess?: () => void,
) {
  const swcOptions = swcDefaultsFactory(extras.tsOptions, configuration);
  const swcrcFilePath = getValueOrDefault<string | undefined>(
    configuration,
    'compilerOptions.builder.options.swcrcPath',
    appName,
  );

  if (extras.watch) {
    if (extras.typeCheck) {
      this.runTypeChecker(configuration, tsConfigPath, appName, extras);
    }
    await this.runSwc(swcOptions, extras, swcrcFilePath);

    if (onSuccess) {
      onSuccess();

      const debounceTime = 150;
      const callback = this.debounce(onSuccess, debounceTime);
      this.watchFilesInOutDir(swcOptions, callback);
    }
  }
  // 省略
}

この処理はoutDirを参照しています。この設定はユーザーが変更していなければtsconfigoutdirを参照するようになっています。問題なく動作しているようです。

private watchFilesInOutDir(
  options: ReturnType<typeof swcDefaultsFactory>,
  onChange: () => void,
) {
  const dir = isAbsolute(options.cliOptions.outDir!)
    ? options.cliOptions.outDir!
    : join(process.cwd(), options.cliOptions.outDir!);
  const paths = join(dir, '**/*.js');
  const watcher = chokidar.watch(paths, {
    ignoreInitial: true,
    awaitWriteFinish: {
      stabilityThreshold: 50,
      pollInterval: 10,
    },
  });
  for (const type of ['add', 'change']) {
    watcher.on(type, async () => onChange());
  }
}

続いて、@swc/cli側の処理を見てみます。

async function dir({ cliOptions, swcOptions }) {
  const { watch } = cliOptions;
  await beforeStartCompilation(cliOptions);
  await initialCompilation(cliOptions, swcOptions);
  if (watch) {
    await watchCompilation(cliOptions, swcOptions);
  }
}

watchオプションが渡されている場合、watchCompilationを実行します。その中で、watchSourcesが実行され、chokidarで監視するファイルを決定します。includeDotfilesfalseになっていると、.から始まるファイル名がすべて無視されます。sourceRoot.に変更したため、すべてのファイルが無視されていました。

const watcher = await watchSources(filenames, includeDotfiles);
export async function watchSources(sources: string[], includeDotfiles = false) {
  const chokidar = await requireChokidar();

  return chokidar.watch(sources, {
    ignored: includeDotfiles
      ? undefined
      : (filename: string) => basename(filename).startsWith("."),
    ignoreInitial: true,
    awaitWriteFinish: {
      stabilityThreshold: 50,
      pollInterval: 10,
    },
  });
}

includeDotfiles

公式ドキュメントによると、

コンパイルできないファイルをコンパイルおよびコピーするときにドットファイルを含めます。

隠しファイルを無視するための設定です。これを有効にすると意図しないディレクトリがビルド対象になる恐れがありますが、未検証です。

最後に

srcディレクトリ以下に全ての実行ファイルを配置すれば問題は解決しますが、公式ドキュメントにはデフォルトから変更するオプションについての説明がもう少しあっても良いと思います。ライブラリがサポートしていることで便利に使える一方で、ブラックボックスになる部分もあるため、しっかりと理解しながら開発することが重要です。

ここまで読んでいただきありがとうございました。

SMARTCAMP Engineer Blog

Discussion