大体なんでも接続する VS Code の Node.js 用デバッガーを気軽に使いたい
VS Code でデバッグを行う場合、ググったり AI に質問すると launch.json
を作成する方法が上位に表示されます(されないこともあります)。しかし、状況によっては事前に設定を作るのは少しばかり手間に感じることもあります。
- 実験用に作った
.js
.ts
ファイルの挙動を確認したい場合 -
npm run test
でちょっとだけ変数の内容を確認したい場合
今回の記事は VS Code + Node.js の組み合わせにおいて、組み込みのデバッガーで気軽にデバッグする方法のメモとなります。
気軽にデバッグする方針
VS Code でのデバッグは「各言語用のデバッグアダプター(拡張機能)」と「デバッグ用に開始されたプロセス(ランタイム)」の接続により実現されます。
図 1-1 Introducing Logpoints and auto-attach より引用
標準的なデバッグでは、デバッグ用にプロセスを開始する方法(引数など)を launch.json
へ記述します。これは、複雑なデバッグの手順を「設定としてプロジェクト内で永続化しやすい(共有しやすい)」という利点を持っています。しかし、ちょっとしたコードのデバッグで設定を記述するのは少し手間だと感じることもあります。
一方で、VS Code + Node.js 用のデバッグ機能(組み込みの js-debug 拡張機能)は node
プロセスへ自動的にアタッチする機能も用意されています。これを利用すると launch.json
の作成や追加の拡張機能などを必要とせず、おまかせでデバッグを開始できるようになっています。
ここからは、Node.js 用のデバッグ機能で提供される「自動アタッチ(Auto Attach)」の利用方法や、ハマりどころなどについて見ていきます。
node
プロセスへ自動的にアタッチしデバッグする
前述のように組み込みのデバッガーは、事前設定なしで node
プロセスへアタッチできるようになっています。
これを利用すると、「単一の .js
.ts
ファイル」や「NPM スクリプト」を気軽にデバッグしたり、「開始タイミングを事前に決定できない node
プロセス」への接続も柔軟に対応できます。
素のスクリプトファイルをデバッグ
まずは、「index.js
を作っただけで package.json
を用意していない状態」でのデバッグ方法です。
デバッグ対象のスクリプトファイル(index.js
)を適当に用意します。内容は console.log(123)
だけでもよいですし、「それっぽい」操作を試したい場合は以下のサンプルをベタっと貼り付けてください。
リスト 2-1 サンプルのスクリプトファイル(https://github.com
から fetch
する)
async function getHtml(){
let ret = ''
const r = await fetch('https://github.com');
if (r.ok) {
for await(const b of r.body){
const text = new TextDecoder().decode(b);
ret += text;
}
} else {
ret= `Failed to fetch the page: ${r.status}, ${r.statusText}`;
}
return ret
}
async function main(){
console.log(await getHtml())
}
main()
スクリプトファイルを用意したら、コマンドパレットから Debug: Toggle Auto Attach
を実行し自動アタッチを有効化します。
図 2-1 自動アタッチ設定用のリスト
有効化するための項目はいくつかありますが、今回は単一の .js
を動かすだけなので Smart
を選択します。選択した後にターミナルを開き node index.js
のように実行すると、以下のようにデバッガーが自動的に node
プロセスへ接続し、デバッグ操作を行えるようになります。
図 2-2 単独のスクリプトファイルを実行している node
プロセスへアタッチしてのデバッグ
また、自動アタッチで開始したデバッグの場合でも、ブレイクポイントでの式やログメッセージなども利用できまます。以下は fetch
した body を何回で受信しているか確認している例です。
図 2-3 カウント表示する式を設定
図 2-4 デバッグを開始するとカウント表示される
このように、ちょっと実験したらすぐに消すようなコードをデバッグする場合、自動アタッチは重宝するかと思います。
TypeScript でデバッグ
TypeScript のソースコードをちょっと動かしたい場合は、 ts-node
や node --experimental-strip-types
などを利用する方法があります。
この場合、とくに意識することなく JavaScript と同じようにデバッグできています。
図 2-5 node --experimental-strip-types
へアタッチしてのデバッグ
一方で TypeScript コードの実行方法としては「tsc
やビルドツールにより .ts
から .js
へトランスパイルする」方法もあります。この場合、「ソースコードとなる .ts
ファイル」と「実際に node
で実行している .js
ファイル」は異なるファイルになるため、ソースファイル上のブレイクポイントなどは認識されません。
対応方法としてはトランスパイル時にソースマップを作成することになります。
ちょっと面倒そうな感じですが、一般的なツールではソースマップ用のオプションが用意されていることが多いので、それらを利用すれば大体は解決できます。
リスト 2-2 tsc
コマンドでソースマップを出力する例
tsc --sourcemap
ソースマップ付きでトランスパイルした後は、.js
を node
で実行することによりデバッグを行えます。
図 2-6 tsc --sourcemap
したコードでのデバッグ
このような感じでソースマップを作成することにより、ソースファイルと実行用ファイルを関連付けできますが、少し注意点もあります。実行方法などによって「型関連のコードなどに設定したブレイクポイントの挙動」が少し異なっています。
たとえば、ぱっと見では関数に見える import() type 行へブレイクポイントを設定してしまった場合の挙動です。この場合 、ts-node
などでは後続のステートメントで停止します。しかし、tsc
でトランスパイルした .js
を node
で実行すると停止しませんでした。
以下は 3 行目と 8 行目にブレイクポイントを設定している状態で試しています。
図 2-7 ts-node
では 4 行目で停止する(ts-node
は 10.9.2
を利用)
図 2-8 tsc --sourcemap
では 3 行目が無視される(tsc
は 5.8.3
を利用)
ソースマップの設定によっても挙動は変化しそうですが、細かく注意するのも少し面倒です。型関連に限らず「トランスパイル後に残らないコードへのブレイクポイント」は避けるように意識した方が良いかと思います。
NPM スクリプトに引数を渡しながらデバッグ
ここまではターミナル上で node
(ts-node
)コマンドを利用した例ですが、自動アタッチはターミナルから開始したコマンドの子プロセスも対象となっています。よって、ユニットテストや開発者モード実行用の NPM スクリプトなどから開始される node
プロセスにも有効です。
こちらも自動アタッチを有効にしたあと、ターミナルから NPM スクリプトを実行するとデバッグが開始されます。なお、以下の例では TypeScript + Jest の場合でデバッグしていますが、jest.config.js
で TypeScript 用に設定してあればとくに意識することは少ないと思います。 (ts-jest
と ts-node
がうまいことやってくれる)
図 2-9 NPM スクリプトへの自動アタッチによるデバッグ
この方法の場合、NPM スクリプトはターミナルから実行になるので、引数や環境変数を渡しやすいなどの利点もあります。
図 2-10 NPM スクリプトに引数を渡す例
npm run test -- --testNamePattern 'should return blank'
注意点としては、フレームワークのコマンドへ自動アタッチする場合は Smart
だと編集中のソースファイルを認識しないことが増えます(ブレイクポイントで一時停止しないなど)。たとえば、Jest では環境(CPU コア数)などにもよりますがこのような状態になります。
フレームワークのコマンド利用時にブレイクポイントで停止しないようなときは、とりあえず Always
を試してみるのがよいかと思います。
node
プロセスでデバッグ
非同期に開始される複数の 自動アタッチでもデバッグを開始した後、さらに他の node
プロセスへアタッチできます(いわゆる Multi-target debugging です)。また、アタッチしたプロセスが 1 つでも残っていればデバッグは継続されます。よって、ユーザーからのリクエストで非同期に開始・終了される node
プロセスでもデバッグを開始できます。
たとえば、以下のような express のサーバーで「ルートによって異なる node
プロセスを開始し結果を受け取る」場合、プロセス開始毎にアタッチしデバッグを行えます。
サンプルソースコード
index.js
const express = require('express');
const { spawn } = require('child_process');
const app = express();
const port = 3000;
function getMiddleware(apiPath) {
return (req, res, next) => {
const child = spawn('node', [`lib/${apiPath}.js`]);
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data;
});
child.stderr.on('data', (data) => {
stderr += data;
});
child.on('close', (code) => {
if (code) {
res.status(500).json({ error: `Child process exited with code ${code}`, stderr });
return;
}
try {
const json = JSON.parse(stdout);
res.json(json);
} catch (e) {
res.status(500).json({ error: 'Invalid JSON output' });
}
});
};
}
app.use('/api1', getMiddleware('api1'));
app.use('/api2', getMiddleware('api2'));
app.listen(port, () => {
console.log(`server listening at http://localhost:${port}`);
});
lib/api1.js
const res = {
name: 'API-1'
}
process.stdout.write(JSON.stringify(res));
lib/api2.js
const res = {
name: 'API-2'
}
process.stdout.write(JSON.stringify(res));
図 2-11 リクエスト(GET
)により開始されたプロセスでのデバッグ(左下の CALL STACK で api2.js
を実行している node
へ接続していることが確認できる)
また、異なるプロセス上で同時に一時停止させることもできます。ただし、ソースファイルの該当箇所への切り替え(表示)は自動的には行われないようです。複数箇所で一時停止しているとき、注目する箇所を変更するには CALL STACK からの切り替え操作になります。
図 2-12 CALL STACK 上で切り替え操作
なお、上記の例では最初にアタッチした node
からの子プロセスにアタッチしていますが、プロセスが親子の関係になっていなくとも複数の node
にアタッチもできています。
図 2-13 2 つのサーバー(node
)を個別に開始しデバッグ
NPM スクリプト用 の UI からデバッグを開始する
ここまで見てきたように、自動アタッチは柔軟にデバッグを開始できるのですが、ターミナルでの操作は避けたいと思うかもしれません。
そのようなとき、NPM スクリプトのデバッグに限れば、NPM Scripts View からデバッグを開始できます。
NPM Scripts View はファイルエクスプローラーの下の方に配置されています。ここで「NPM SCRIPTS」横の >
をクリックするとスクリプト一覧が表示されます。 (配置されていないときは、コマンドパレットから Explorer: Focus on NPM Scripts View
を選択すると表示されます)
図 3-1 左下に表示される NPM Scripts View
スクリプト一覧が表示されたらマウスカーソルを合わせるとデバッグ用と通常 Run 用のアイコンが表示されます。
図 3-2 デバッグアイコン
ここでデバッグ用アイコンをクリックするとターミナル(JavaScript Debug Terminal)内で NPM スクリプトが開始され、デバッガーがアタッチするようになっています。
図 3-3 アタッチした node
プロセスのスクリプトをデバッグ
このような感じで、NPM スクリプトから開始されるコードをデバッグしたい場合は、UI の操作だけでも気軽に利用できます。
ただし、以下のようなデメリットもあります。
- NPM スクリプト(
package.json
)を用意する必要がある - VS Code に
package.json
を認識させる必要がある(NPM Scripts View はワークスペース直下にpackage.json
がないと使えないようです) - NPM スクリプト開始時に一時的な引数や環境変数を渡せない(たぶん)
Node.js へ自動アタッチできるカラクリ
通常、Node.js をデバッグ用に開始する場合、 --inspect
などのフラグを指定するようになっています。
ところが、ここまでに試した方法では node
開始時にフラグを指定しないでもアタッチできました。
実は、現在の JS 用デバッガー(拡張機能)では自動アタッチを有効化しているとターミナル内では $NODE_OPTIONS
環境変数などが設定されます。これにより、デバッグ用のブートローダースクリプトが読み込まれるようになっています。
以下は、 Smart
を指定している状態でコマンドパレットから Terminal: Show Environment Contribution
を実行した結果の抜粋です。
# Terminal Environment Changes
## Extension: ms-vscode.js-debug
Enables Node.js [auto attach](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach) debugging in "smart" mode
Enables Node.js [auto attach](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_auto-attach) debugging in "smart" mode (workspace)
- `NODE_OPTIONS= --require /home/node/.vscode-server/data/User/workspaceStorage/2bff38b2ddf6c672f14e0e680fdcadc2/ms-vscode.js-debug/bootloader.js ${env:NODE_OPTIONS}`
- `VSCODE_INSPECTOR_OPTIONS=${env:VSCODE_INSPECTOR_OPTIONS}:::{"inspectorIpc":"/tmp/node-cdp.362-77ca0357-0.sock.deferred","deferredMode":true,"waitForDebugger":"","execPath":"/usr/local/bin/node","onlyEntrypoint":false,"autoAttachMode":"smart","mandatePortTracking":true,"aaPatterns":["/workspaces/test-auto-attach-node-ts/**","!**/node_modules/**","**/$KNOWN_TOOLS$/**"]}`
逆に言うと、これら環境変数やブートローダーのファイル(と内部で利用される IPC)などが正しく設定されていないと想定通りに動作しなくなります。
- 利用しているフレームワークが
$NODE_OPTIONS
を上書きしている - 実行環境がコンテナ内にあり、ブートローダーや IPC を参照できない状態でスクリプトが開始される
使っているフレームワークなどによりもますが、想定通りにデバッグが開始されないときは、上記のような点を意識していると原因を切り分けしやすいかと思います。
デバッグを上手く開始できないとき
デバッグ機能を利用していたときに遭遇した「上手くいかない状況とその回避方法」についてのメモです。
アタッチされない
よくあるのは、自動アタッチを有効化した後に新しくターミナルを作成していない場合です。
自動アタッチは新しくターミナルを開く時に $NODE_OPTIONS
などを設定することで実現されます。よって、すでに開いていたターミナルでは環境変数と実際の設定が食い違うため、自動アタッチが行われないことになります。
なお、ターミナルの環境変数などが食い違っているときは、以下のように ⚠ 表示で警告されることもあります(理由は不明ですが、されないこともあります)。
よって、基本的な対処方法としては「まずはターミナルを開き直してみる」です。
Smart
を利用している)
アタッチはされるがソースファイルを認識しない (一見するとプロセスにアタッチしてデバッグできるように見えるが、ソースファイル上のブレイクポイントで一時停止しないようなこともあります。
これは、自動アタッチの Smart
とフレームワーク(Jest や Next.js など)の組み合わせで発生することが多いです。
アタッチされても一時停止しない状況について、Jest の場合で少し詳細を見ていきます(長いの折りたたんでいます)。
対処方法としては「自動アタッチを Always
にしてみる」です。あとは、設定を追加することになりますが、Auto Attach Smart Patterns を指定するという方法もあります。
アタッチはされるがソースファイルを認識しない (ソースマップがない)
ビルド(バンドル)した CLI ツールをデバッグしようとしたときにわりと遭遇しました。 (最近も、ちょっとしたツール作っているとき「なぜ止まらん?」と少し悩んでしまいました)
たとえば、 npm run start
で実行する .js
をバンドルツールでビルドしている場合、プロダクション用としてソースマップを作成しないことが多いかと思います。この場合、「node
プロセスから実際に利用される .js
ファイル」と「エディター上で編集しているソースファイル」は別ファイル扱いなので、ブレイクポイントなどの指定には利用できないことになります。
対処方法としては「ソースマップを適切に作成する」です。
リスト 5-1 デバッグ用の NPM スクリプトを追加した例(build.sh
は --sourcemap
でソースマップを作成する前提)
"scripts": {
"build": "scripts/build.sh",
"clean": "scripts/clean.sh",
"start": "node dist/index.js",
"prerestart": "npm run clean && npm run build",
"debug": "node dist/index.js",
"predebug": "npm run clean && npm run build -- --sourcemap"
},
なお、一般的なバンドルツールではなく「独自にファイル連結などでスクリプトファイルを加工している」ような場合だと対応はちょっと難しいかもしれません。
npm ci
などが失敗する
デバッグとはちょっと離れた話になりますが、自動アタッチ関連のハマり所なので少し記述しておきます。
原因は不明なのですが、 npm ci
などのコマンドでエラーとなることもあります。 (自動アタッチを有効化している状態で Dev Container を作成した直後に npm ci
すると発生するように感じるが確証はないです)
node:internal/modules/cjs/loader:1404
throw err;
^
Error: Cannot find module '/home/node/.vscode-server/data/User/workspaceStorage/5ad91349dab35d9a960785501ba3c09b/ms-vscode.js-debug/bootloader.js'
Require stack:
- internal/preload
at Function._resolveFilename (node:internal/modules/cjs/loader:1401:15)
at defaultResolveImpl (node:internal/modules/cjs/loader:1057:19)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1062:22)
at Function._load (node:internal/modules/cjs/loader:1211:37)
at TracingChannel.traceSync (node:diagnostics_channel:322:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:235:24)
at Module.require (node:internal/modules/cjs/loader:1487:12)
at node:internal/modules/cjs/loader:2045:12
at loadPreloadModules (node:internal/process/pre_execution:743:5)
at setupUserModules (node:internal/process/pre_execution:208:5) {
code: 'MODULE_NOT_FOUND',
requireStack: [ 'internal/preload' ]
}
Node.js v22.16.0
この場合はターミナルを開き直しても回避できず、一旦 Disable
してからターミナルを開きなおす必要があるようです。
ターミナル多重化環境との相性
私はターミナル内で Tmux(ターミナル多重化)を使うこともあるので、Tmux ならでの注意点を少し。
環境変数を必要とする拡張機能と、ターミナル多重化環境の組み合わせは想定通りに動作しないことも多いです。これは、VS Code による環境変数書き換え処理が、多重化ツールのセッション(サーバープロセス)まで波及しないことが原因です。 (ターミナル多重化を活用している人にはいまさらな話であり、上手く回避している人も多いかとは思いますが…)
ターミナルを新しく開き直しても想定したようにデバッグが開始されないときは、多重化ツールのセッションも再作成されているか意識すると解決しやすいかと思います。
考慮点
手順を永続化しにくい
launch.json
により設定を準備するということは、以下のような項目を永続化されたコードとしてプロジェクト(リポジトリ)へ保存することを意味します。
- スクリプト開始時の引数指定
- 環境変数の設定
- 実行時のカレントディレクトリ設定
- Node.js のバージョンやランタイム設定
- リモートアタッチの設定
よって、一時的には手間ではありますが、デバッグ開始手順の再利用や共有がされるやすくなると言えます。
一方で自動アタッチは気軽に利用できますが、ターミナル上での入力は設定として残りにくいと言えます。
裏技的な引数などが増えてしまいそうなときは、スクリプト化しておくようなことも意識した方が良いかと思います。 (あるいは、いまどきだと「AI エージェントなどが参照するナレッジベースへ手順を記述しておく」という方法もありかもしれません)
デバッグ用拡張機能
今回は気軽に開始できることを優先していたので触れませんでしたが、各種フレームワーク用に用意されている拡張ではデバッグを支援するものもあります。
たとえば、デバッグするテスト関数を引数で指定するのは手間だと感じることもありますが、そのような操作を UI で行える拡張機能もあるようです。
デバッグ用の環境が固まってきたら、デバッグ操作を支援してくれる拡張機能を探してみるのも良いかもしれません。
おわりに
VS Code と Node.js の組み合わせにおいて、launch.json
を作成することなくデバッグする方法を確認しました。
Node.js への自動アタッチ機能は気軽に利用できのと同時に柔軟な接続も可能なため、これまでデバッグをためらっていたような場面でもコードの挙動を確認しやすくなるかと思います。
Discussion