# Node.js / tsx / ts-node / Bun / Deno Debugging Covers Node 18+, tsx, ts-node, Bun, Deno. Launch recipes, inspector protocol usage, the `node inspect` CLI, and the **tsx source-map silent-failure** that costs people days. --- ## Environment detection (Phase 0) ```bash node --version cat package.json | head -40 # Which JS runtime launches the app? (order them; the first match wins) ls node_modules/.bin/tsx 2>/dev/null && echo 'has tsx' ls node_modules/.bin/ts-node 2>/dev/null && echo 'has ts-node' ls node_modules/.bin/vitest 2>/dev/null && echo 'has vitest' which bun 2>/dev/null && bun --version which deno 2>/dev/null && deno --version # Source-map situation grep -E '"sourceMap"|"inlineSources"' tsconfig.json 2>/dev/null grep -l '//# sourceMappingURL' dist/*.js 2>/dev/null | head -3 # Debug-relevant ports lsof -iTCP:9229 -sTCP:LISTEN -nP 2>/dev/null lsof -iTCP:9230 -sTCP:LISTEN -nP 2>/dev/null ``` --- ## 🚨 The tsx + `node inspect` CLI silent-failure (READ THIS) `tsx` transpiles each `.ts` file on the fly and emits an inline source map. V8 Inspector registers the module with its `.ts` path (so it shows up in the debugger's `scripts` list), **but the `node inspect` CLI REPL does not resolve source-map line numbers reliably**. Setting `sb('session.ts', 285)` will show a "pending" breakpoint that **never fires even after the module loads**. The breakpoint list will happily display it, so you think it's set. It isn't. ### Three reliable workarounds | Workaround | When to use | Downside | |---|---|---| | **`debugger;` statement in source** | You can edit the source, CLI required | Requires source edit + revert | | **Chrome DevTools GUI** (`chrome://inspect`) | CLI not required, faster iteration | Not usable if user specifically asked for CLI | | **Debug the built `dist/` JS** | Source maps are working end-to-end | Requires `npm run build` on every source change | The `debugger;` statement is the most reliable. Journal the edit — revert at Phase 9. --- ## Launch recipes by runtime ### Node (plain JS / compiled TS) ```bash # Break on first line, wait for debugger to attach node --inspect-brk=9229 dist/index.js # Attach immediately, don't block startup — pair with debugger; statements node --inspect=9229 dist/index.js # Wait for debugger to attach, THEN run (new in Node 20.15+) node --inspect-wait=9229 dist/index.js # Source maps in stack traces (always a good idea in debug builds) node --enable-source-maps --inspect dist/index.js ``` ### tsx ```bash # The tsx runner is --import-compatible, so these work: node --inspect-brk=9229 --import tsx index.ts node --inspect=9229 --import tsx index.ts # If user prefers invoking tsx directly, this also works but is less explicit: NODE_OPTIONS='--inspect-brk=9229' npx tsx index.ts # ⚠️ tsx watch + inspector = inspector reloads per file change # Debug without watch: node --inspect=9229 --import tsx index.ts # (no `watch`) ``` ### ts-node (legacy but still encountered) ```bash node --inspect-brk -r ts-node/register src/index.ts # ESM (ts-node's ESM loader is fragile — if possible, migrate to tsx): node --inspect --loader ts-node/esm src/index.ts ``` ### Bun (WebKit Inspector Protocol, NOT V8) ```bash bun --inspect src/index.ts # opens debug.bun.sh URL bun --inspect-brk src/index.ts # break on start bun --inspect-wait src/index.ts # wait for attach bun test --inspect-brk # debug test runner ``` **Critical**: Bun uses WebKit Inspector Protocol, not V8. `chrome://inspect` cannot connect directly. Use `debug.bun.sh` or the (currently buggy, per Bun docs) VS Code extension. ### Deno (native V8, Chrome DevTools / VS Code compatible) ```bash deno run --inspect-brk --allow-all src/main.ts deno test --inspect-brk --filter "auth" ``` Deno is the smoothest TS debugging experience — native V8 inspector, no source-map workarounds. ### Vitest ```bash # Single worker required — inspector can't attach to multiple workers vitest --inspect-brk --no-file-parallelism vitest --inspect-brk --browser --no-file-parallelism # browser mode ``` Without `--no-file-parallelism`, breakpoints won't fire because the process Vitest spawns workers in isn't the one listening on the inspector port. --- ## Attaching with `node inspect` CLI ```bash node inspect 127.0.0.1:9229 # attach to an existing --inspect process ``` Core commands at the `debug>` prompt: ``` cont, c resume until next break / debugger; next, n step over step, s step into out, o step out pause pause a running process bt backtrace scripts list all modules V8 has loaded (incl. tsx-transpiled .ts) sb(N) set breakpoint at line N of current file sb('file', N) set breakpoint at line N of matching file (⚠️ unreliable with tsx) sb(func) set breakpoint at function reference cb(N), cb('file', N) clear breakpoint breakpoints list breakpoints (shows pending ones, doesn't tell you they'll never fire) watch('expr') persistent watch expression watchers show watchers exec('expr') evaluate expression in paused frame's scope repl drop into full REPL with frame's scope restart restart the debuggee kill kill the debuggee ``` **`exec('expr')` is the most powerful tool in this CLI** — it evaluates any JS in the paused frame and returns the value. Use it heavily. --- ## `exec()` patterns that resolve hypotheses fast At a breakpoint, these queries resolve most LLM / agent / async bugs in one line each: ```js // Agent / LLM state exec('this.agent.state.messages.length') exec('this.agent.state.messages.map(m => m.role)') exec('JSON.stringify(this.agent.state.messages.at(-1)).substring(0, 500)') exec('this.agent.state.messages.at(-1).errorMessage') // silent-error sentinel exec('this.agent.state.messages.at(-1).stopReason') exec('JSON.stringify(this.agent.state.usage)') // undefined / all-zero = failed call exec('this.agent.state.model.baseUrl') // catch hardcoded vs env-var // Env / config at runtime exec('process.env.RELEVANT_VAR') exec('Object.keys(process.env).filter(k => k.startsWith("ANTHROPIC"))') exec('this.config') // Async / timing exec('Date.now() - this._turnStartedAt') exec('this._activePromises?.size') // HTTP request/response in-flight exec('JSON.stringify(req.body).length') exec('res.statusCode') exec('res.headersSent') // What's actually running exec('process.version') exec('process.cwd()') exec('process.argv') ``` --- ## Silent-failure patterns in Node These are the patterns that most commonly look like success but aren't. Always check when a response is "too fast" or "too empty": | Signal | What it means | |---|---| | HTTP 200 + `content: ""` | Silent error swallowed | | HTTP 200 + response in <1s for an LLM call | Too fast for a real Claude/GPT call; something short-circuited | | `usage: { totalTokens: 0 }` | LLM SDK returned a stub without making the call | | `stopReason: "error" + content: []` | SDK packaged an error into a "success" message | | Unhandled promise rejection with no log | Caller forgot to `await`, or `.catch(() => {})` | | `try { await x(); } catch {}` | Error eaten, no log | | `void somePromise()` | Explicit opt-out of error propagation; often a bug | | Callback-style API where callback never fires | Error happened before callback scheduled | | Handler returns `res.json(...)` twice | Second call is silent on some Express versions | When you find one, add a temporary `console.error('[DEBUG]', ...)` to make it loud — journal it, revert at Phase 9. --- ## tmux session layout (two sessions, one purpose each) ```bash # Long-running inspected process tmux new-session -d -s debug-server -c "$PWD" tmux send-keys -t debug-server 'node --inspect=9229 --import tsx index.ts' Enter # Interactive debugger client (separate pane for readability) tmux new-session -d -s debug-client -c "$PWD" tmux send-keys -t debug-client 'node inspect 127.0.0.1:9229' Enter # Non-blocking pane inspection from the outside tmux capture-pane -p -t debug-server -S -50 ``` Journal both session names. Kill both at Phase 9: ```bash tmux kill-session -t debug-server tmux kill-session -t debug-client ``` --- ## When to abandon the CLI and switch to Chrome DevTools The user's preference for CLI is valid and should be respected. But you may recommend a switch in one short sentence if ANY of these hold: - You hit source-map resolution failures (`sb('file', line)` not firing) AND the fix is time-sensitive - You need to watch many values simultaneously (GUI watch panel is faster to scan) - You're stepping through async-heavy code where CLI step semantics get murky across microtask boundaries Phrase as a note, not a request: "I can push through with `debugger;` statements in CLI. If we hit three or more of these in a row, switching to `chrome://inspect` GUI would cut cycle time in half — your call." --- ## Phase 9 cleanup specifics ```bash # Revert source-level debug statements git diff | grep -E '(debugger;|console\.log\(.*DEBUG|\[ARBITER-DEBUG|\[DEBUG)' # Revert any matching files: git checkout # Kill inspector-attached processes pkill -f 'node --inspect' || true pkill -f 'bun --inspect' || true pkill -f 'deno.*--inspect' || true lsof -iTCP:9229 -sTCP:LISTEN -nP 2>/dev/null ```