Node Inspect Debugger
目次
--inspect と Chrome DevTools Protocol の CLI で Node.js をデバッグします。
skill の情報
| 提供元 | 最初から入っています |
| パス | skills/software-development/node-inspect-debugger |
| バージョン | 1.0.0 |
| 作者 | Hermes Agent |
| ライセンス | MIT |
| 対応プラットフォーム | linux, macos, windows |
| タグ | debugging, nodejs, node-inspect, cdp, breakpoints, ui-tui |
| 関連 skill | systematic-debugging, python-debugpy |
参考: SKILL.md 全文
Node.js の Inspect デバッガ
概要
console.log では足りないときに、Node に組み込みの V8 インスペクタをターミナルからプログラム的に動かします。本物のブレークポイント、ステップイン / オーバー / アウト、コールスタックの追跡、ローカル変数やクロージャのスコープの一覧、そして停止したフレームでの任意の式の評価ができます。
道具は2つあります。どちらかを選んでください。
node inspect— 組み込みで、導入不要の CLI の REPL。ちょっと覗くのに向いています。ndb/chrome-remote-interface経由の CDP — Node や Python からスクリプトで動かせます。多数のブレークポイントを自動で仕掛けたい、複数回の実行にまたがって状態を集めたい、エージェントのループから対話なしでデバッグしたい、といった場合に向いています。
まずは node inspect を選んでください。 いつでも使えて、REPL の反応も速いです。
こんなときに使います
- Node のテストが落ちて、途中の状態を見たいとき
- ui-tui が落ちる、または動きがおかしくて、描画前の React/Ink の状態を調べたいとき
- tui_gateway の子プロセス(
_SlashWorker、PTY のブリッジ用ワーカー)の様子がおかしいとき - クロージャの中の値を見たいが、
console.logを差し込まないと届かないとき - 性能: 動いているプロセスに接続して、CPU プロファイルやヒープスナップショットを取りたいとき
使わない場面: console.log で1分もかからず片づくこと。ブレークポイントを使うデバッグは手間が大きいので、それに見合う場面で使ってください。
早見表: node inspect の REPL
最初の行で止めた状態で起動します。
node inspect path/to/script.js
# or with tsx
node --inspect-brk $(which tsx) path/to/script.tsdebug> のプロンプトでは次が使えます。
| コマンド | 動き |
|---|---|
c または cont |
実行を続ける |
n または next |
ステップオーバー |
s または step |
ステップイン |
o または out |
ステップアウト |
pause |
実行中のコードを止める |
sb('file.js', 42) |
file.js の 42 行目にブレークポイントを置く |
sb(42) |
今のファイルの 42 行目にブレークポイントを置く |
sb('functionName') |
その関数が呼ばれたら止める |
cb('file.js', 42) |
ブレークポイントを消す |
breakpoints |
ブレークポイントを一覧する |
bt |
バックトレース(コールスタック) |
list(5) |
今の位置の前後5行のソースを表示する |
watch('expr') |
止まるたびに expr を評価する |
watchers |
監視している式を表示する |
repl |
今のスコープの REPL に入る(Ctrl+C で REPL を抜ける) |
exec expr |
式を1回だけ評価する |
restart |
スクリプトを再起動する |
kill |
スクリプトを終了する |
.exit |
デバッガを終了する |
repl のモードでは: ローカル変数やクロージャの変数へのアクセスを含め、任意の JS の式を書けます。Ctrl+C で debug> に戻ります。
動いているプロセスに接続する
対象がすでに動いている場合(長く動かしている開発サーバーや TUI のゲートウェイなど):
# 1. Send SIGUSR1 to enable the inspector on an existing process
kill -SIGUSR1 <pid>
# Node prints: Debugger listening on ws://127.0.0.1:9229/<uuid>
# 2. Attach the debugger CLI
node inspect -p <pid>
# or by URL
node inspect ws://127.0.0.1:9229/<uuid>最初からインスペクタつきでプロセスを起動する場合:
node --inspect script.js # listen on 127.0.0.1:9229, keep running
node --inspect-brk script.js # listen AND pause on first line
node --inspect=0.0.0.0:9230 script.js # custom host:porttsx 経由の TypeScript の場合:
node --inspect-brk --import tsx script.ts
# or older tsx
node --inspect-brk -r tsx/cjs script.tsCDP をプログラムから使う(ターミナルから自動化する)
自動化したいとき — ブレークポイントをたくさん仕掛ける、スコープの状態を集める、再現手順をスクリプトにする — には chrome-remote-interface を使います。
npm i -g chrome-remote-interface # or project-local
# Start your target:
node --inspect-brk=9229 target.js &動かす側のスクリプト(/tmp/cdp-debug.js として保存します):
const CDP = require('chrome-remote-interface');
(async () => {
const client = await CDP({ port: 9229 });
const { Debugger, Runtime } = client;
Debugger.paused(async ({ callFrames, reason }) => {
const top = callFrames[0];
console.log(`PAUSED: ${reason} @ ${top.url}:${top.location.lineNumber + 1}`);
// Walk scopes for locals
for (const scope of top.scopeChain) {
if (scope.type === 'local' || scope.type === 'closure') {
const { result } = await Runtime.getProperties({
objectId: scope.object.objectId,
ownProperties: true,
});
for (const p of result) {
console.log(` ${scope.type}.${p.name} =`, p.value?.value ?? p.value?.description);
}
}
}
// Evaluate an expression in the paused frame
const { result } = await Debugger.evaluateOnCallFrame({
callFrameId: top.callFrameId,
expression: 'typeof state !== "undefined" ? JSON.stringify(state) : "n/a"',
});
console.log('state =', result.value ?? result.description);
await Debugger.resume();
});
await Runtime.enable();
await Debugger.enable();
// Set a breakpoint by URL regex + line
await Debugger.setBreakpointByUrl({
urlRegex: '.*app\\.tsx$',
lineNumber: 119, // 0-indexed
columnNumber: 0,
});
await Runtime.runIfWaitingForDebugger();
})();実行します。
node /tmp/cdp-debug.jsHermes 固有の注意: chrome-remote-interface は ui-tui/package.json に入っていません。プロジェクトを汚したくない場合は、使い捨ての場所に入れてください。
mkdir -p /tmp/cdp-tools && cd /tmp/cdp-tools && npm i chrome-remote-interface
NODE_PATH=/tmp/cdp-tools/node_modules node /tmp/cdp-debug.jsHermes の ui-tui をデバッグする
TUI は Ink と tsx で作られています。よくあるのは次の2つの場面です。
開発中の Ink コンポーネント1つをデバッグする
ui-tui/package.json には npm run dev(tsx --watch)があります。tsx を直接動かして --inspect-brk を足します。
cd <hermes-agent-repo>/ui-tui
npm run build # produce dist/ once so transpile isn't needed on first load
node --inspect-brk dist/entry.js
# In another terminal:
node inspect -p <node pid>そのうえで debug> の中で:
sb('dist/app.js', 220) # or wherever the suspect render is
cont止まったら repl に入り、props、state の参照、useInput のハンドラの値などを調べます。
動いている hermes --tui をデバッグする
TUI は Python の CLI から Node を起動します。いちばん簡単な手順は次のとおりです。
# 1. Launch TUI
hermes --tui &
TUI_PID=$(pgrep -f 'ui-tui/dist/entry' | head -1)
# 2. Enable inspector on that Node PID
kill -SIGUSR1 "$TUI_PID"
# 3. Find the WS URL
curl -s http://127.0.0.1:9229/json/list | jq -r '.[0].webSocketDebuggerUrl'
# 4. Attach
node inspect ws://127.0.0.1:9229/<uuid>TUI をそのまま操作しても(その画面で入力しても)実行は進みます。デバッガ側は、sb(...) を置いたところでいつでも止められます。
_SlashWorker と PTY の子プロセスをデバッグする
これらは Node ではなく Python なので、python-debugpy の skill を使ってください。この skill が対象にするのは Node の部分だけです(Ink の UI、tui_gateway のクライアント、ui-tui/ 配下の tsx で動くテスト)。
デバッガの下で Vitest のテストを走らせる
cd <hermes-agent-repo>/ui-tui
# Run a single test file paused on entry
node --inspect-brk ./node_modules/vitest/vitest.mjs run --no-file-parallelism src/app/foo.test.tsx別のターミナルで node inspect -p <pid> を実行し、sb('src/app/foo.tsx', 42)、cont と進めます。
ワーカーが1つだけになるよう --no-file-parallelism(vitest)か --runInBand(jest)を使ってください。並列で動くものをデバッグするのは骨が折れます。
ヒープスナップショットと CPU プロファイル(対話なし)
先ほどの CDP のスクリプトで、Debugger の代わりに HeapProfiler / Profiler を使います。
// CPU profile for 5 seconds
await client.Profiler.enable();
await client.Profiler.start();
await new Promise(r => setTimeout(r, 5000));
const { profile } = await client.Profiler.stop();
require('fs').writeFileSync('/tmp/cpu.cpuprofile', JSON.stringify(profile));
// Open /tmp/cpu.cpuprofile in Chrome DevTools → Performance tab// Heap snapshot
await client.HeapProfiler.enable();
const chunks = [];
client.HeapProfiler.addHeapSnapshotChunk(({ chunk }) => chunks.push(chunk));
await client.HeapProfiler.takeHeapSnapshot({ reportProgress: false });
require('fs').writeFileSync('/tmp/heap.heapsnapshot', chunks.join(''));よくある落とし穴
- TS のソースで行番号がずれる。 ブレークポイントが当たるのは
.tsではなく、生成された JS です。(a) ビルド後のdist/*.jsで止めるか、(b) ソースマップを有効にして(node --enable-source-maps)sb('src/app.tsx', N)を使ってください。ただし後者は、ソースマップを追える CDP のクライアントでしか使えません。node inspectの CLI は追えません。
--inspectと--inspect-brkの違い。--inspectはインスペクタを起動するだけで止めないので、接続が遅れると最初のブレークポイントを通り過ぎてしまいます。コードが動き出す前にブレークポイントを置きたいときは--inspect-brkを使ってください。
- ポートの衝突。 既定は
9229です。複数の Node プロセスを調べている場合は--inspect=0(空きポートを自動で選ぶ)を渡し、実際の URL を/json/listから読み取ってください:
curl -s http://127.0.0.1:9229/json/list # lists all inspectable targets on the host- 子プロセス。 親に
--inspectを付けても、子は対象になりません。すべての子に広げるにはNODE_OPTIONS='--inspect-brk' node parent.jsを使います。その場合、子ごとに別のポートが必要になります(NODE_OPTIONS='--inspect'が引き継がれると、Node が番号を自動で繰り上げます)。
- 止め忘れ。 対象が止まったままの状態で
node inspectをCtrl+Cで抜けると、対象は止まったままになります。先にcontするか、対象を明示的にkillしてください。
- エージェントのターミナルから
node inspectを動かす場合。 これは PTY 向けの REPL です。Hermes ではterminal(pty=true)か、background=trueとprocess(action='submit', data='...')の組み合わせで起動してください。PTY を使わない前面での実行は、単発のコマンドなら動きますが、対話的なステップ実行はできません。
- 安全性。
--inspect=0.0.0.0:9229は任意のコードの実行を外部にさらします。隔離されたネットワークでない限り、必ず127.0.0.1(既定)に限定してください。
確認リスト
デバッグの準備ができたら、次を確認します。
- [ ]
curl -s http://127.0.0.1:9229/json/listが、意図した対象だけを返す - [ ] 最初のブレークポイントが実際に当たる(当たらないなら、
--inspect-brkを付け忘れたか、実行が終わったあとに接続した可能性が高いです) - [ ] 停止時に表示されるソースが、正しいファイルである(食い違うならソースマップの問題です。落とし穴の1を参照)
- [ ]
replでexec process.pidを実行すると、接続したかった PID が返る
単発のレシピ
「なぜ X 行目でこの変数が undefined なのか」
node --inspect-brk script.js &
node inspect -p $!
# debug>
sb('script.js', X)
cont
# paused. Now:
repl
> myVariable
> Object.keys(this)「この関数はどの経路から呼ばれているのか」
debug> sb('suspectFn')
debug> cont
# paused on entry
debug> bt「この非同期の連鎖が止まる。どこで止まっているのか」
# Start with --inspect (no -brk), let it run to the hang, then:
debug> pause
debug> bt
# Now you see the stuck frame