NestJSのビルドをswcを使って高速したかったが苦戦した話
はじめに
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.json
のsourceRoot
を.
に設定していると発生することがわかりました。詳しくは後述します。
解決策
解決策を先に示します。nest-cli.json
を以下のようにすることで上述した問題が解消されました。
"sourceRoot": ".",
"entryFile": "./src/main",
"compilerOptions": {
"builder": {
"type": "swc",
"options": {
"stripLeadingPaths": false,
"includeDotfiles": true
}
}
}
処理の流れ
NestJSでSWCオプションを有効にすると、大まかに以下の流れで処理が行われます:
-
@nestjs/cli
:nest-cli.json
の設定を元に@swc/cli
にビルドを指示 -
@swc/cli
: TypeScriptからJavaScriptへのビルドを実行
watchオプションを有効にした場合は以下が追加されます:
-
@swc/cli
: TypeScriptファイルの変更を検知し、ビルドを実行 -
@swc/cli
:dist/
に結果が出力される -
@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,
},
});
このデフォルトの設定を見ると、stripLeadingPaths
がtrue
になっていました。これを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
を参照しています。この設定はユーザーが変更していなければtsconfig
のoutdir
を参照するようになっています。問題なく動作しているようです。
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
で監視するファイルを決定します。includeDotfiles
がfalse
になっていると、.
から始まるファイル名がすべて無視されます。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
ディレクトリ以下に全ての実行ファイルを配置すれば問題は解決しますが、公式ドキュメントにはデフォルトから変更するオプションについての説明がもう少しあっても良いと思います。ライブラリがサポートしていることで便利に使える一方で、ブラックボックスになる部分もあるため、しっかりと理解しながら開発することが重要です。
ここまで読んでいただきありがとうございました。
Discussion