@saptools/cf-inspector
v0.4.1
Published
Set breakpoints, capture variable snapshots, and evaluate expressions on a SAP BTP Cloud Foundry Node.js app via the Chrome DevTools Protocol — agent-friendly, no IDE required.
Maintainers
Readme
🔍 @saptools/cf-inspector
Set breakpoints, capture variable snapshots, and evaluate expressions on a remote Node.js process — over the Chrome DevTools Protocol, no IDE required.
Built so an AI agent (or a CI job) can drive a debugger from a single shell command. Pairs with @saptools/cf-debugger when the target lives behind a Cloud Foundry SSH tunnel.
Install • Quick Start • CLI • API • How it works
✨ Features
- 🎯 One-shot snapshot —
cf-inspector snapshot --bp src/handler.ts:42sets the breakpoint, waits for it to hit, captures requested expressions, auto-resumes, prints JSON, exits - ✅ Conditional breakpoints —
--condition 'req.userId === "abc"'only pauses when the predicate is truthy - 🔢 Hit-count breakpoints —
--hit-count 5skips the first N − 1 hits and pauses on the Nth, on every command (snapshot, log, watch) - 🎭 Multi-breakpoint — repeat
--bpto race several locations; first hit wins - 🪜 Stack capture —
--stack-depth N --stack-captures 'this, args'walks call frames and evaluates expressions per frame - 🔁 Watch streaming —
cf-inspector watch --bp file:line --capture user.id --duration 30re-captures on every hit and emits JSON Lines (the streaming counterpart ofsnapshot) - 💥 Exception breakpoints —
cf-inspector exception --type uncaught --capture err.messagepauses on the next thrown error and materializes the exception value - 📡 Non-pausing logpoints —
cf-inspector log --at file:line --expr 'JSON.stringify({…})'streams JSON Lines as the line executes, without ever pausing the inspectee (safe for production traffic), with optional--condition,--hit-count,--max-events - 🧠 Agent-friendly — JSON-by-default I/O, deterministic shape, bounded value previews for large debugger payloads
- 🧭 Path mapping — local
src/handler.ts:42is matched against the remote URL via aurlRegex, with optional--remote-rootliteral or regex (same DSL ascds-debug) - 🔁 Composes with
cf-debugger— pass--app/--region/--org/--spaceand the tunnel is opened automatically; pass--portto attach to anything CDP-speaking - 🪶 Tiny dependency footprint —
commander+wsonly, no heavy CDP framework - 🧩 Typed API — every CLI command has a programmatic equivalent with full TypeScript definitions
📦 Install
npm install -g @saptools/cf-inspector
# or
pnpm add @saptools/cf-inspector[!NOTE] Requires Node.js ≥ 20. For Cloud Foundry targets, also install
@saptools/cf-debugger(added automatically as a peer-style runtime dep).
🚀 Quick Start
Cloud Foundry app (auto-tunnel)
export SAP_EMAIL=...
export SAP_PASSWORD=...
cf-inspector snapshot \
--region eu10 --org my-org --space dev --app my-srv \
--bp src/handler.ts:42 \
--remote-root 'regex:^/(home/vcap/app|example-root-.*)$'This command internally calls @saptools/cf-debugger to open the SSH tunnel, runs the snapshot through it, and tears the tunnel down on exit.
🧰 CLI
📸 cf-inspector snapshot
Set one or more breakpoints, wait for any of them to hit, capture frame metadata and requested expressions, auto-resume, exit.
# Conditional snapshot — only pauses for the user we care about
cf-inspector snapshot --port 9229 \
--bp src/handler.ts:42 \
--condition 'req.userId === "abc"' \
--capture 'req.body'
# Multi-breakpoint — first hit wins (useful when you don't know which path is taken)
cf-inspector snapshot --port 9229 \
--bp src/auth.ts:120 \
--bp src/auth.ts:155 \
--bp src/auth.ts:180 \
--capture 'req.url, this.user'| Flag | Description |
| --- | --- |
| --port <number> | Local port the inspector or tunnel listens on. Required unless --app/--region/--org/--space are all set |
| --bp <file:line> | Required. Source location to break at. Pass multiple times to race several locations — the first one to hit wins |
| --condition <expr> | Only pause when this JS expression evaluates truthy in the paused frame. Errors in the condition are silently treated as false by V8 |
| --hit-count <n> | Skip the first N − 1 hits and only pause on the Nth (combines with --condition via logical AND) |
| --capture <expr,…> | Top-level comma-separated expressions to evaluate in the paused frame; nested commas inside objects, arrays, calls, or strings are preserved. Object results are materialized to JSON strings when serializable, with fallback to CDP descriptions for non-serializable values |
| --stack-depth <n> | Walk this many call frames per hit (default: 1, top frame only). When > 1, the result includes a stack array |
| --stack-captures <expr,…> | Expressions to evaluate on each call frame in the captured stack |
| --timeout <seconds> | How long to wait for the breakpoint to hit (default: 30) |
| --max-value-length <chars> | Maximum characters per captured value before truncation (default: 4096) |
| --remote-root <value> | Optional path-mapping anchor: literal path or regex:<pattern> / /pattern/flags |
| --include-scopes | Include expanded paused-frame scopes under topFrame.scopes. Omitted by default to keep targeted captures concise |
| --no-json | Print a human-readable summary instead of JSON |
| --keep-paused | Skip Debugger.resume after capture |
| --fail-on-unmatched-pause | Fail immediately if the target pauses somewhere else instead of waiting cooperatively |
Snapshot JSON includes frame metadata and captures by default. topFrame.scopes
is only present with --include-scopes because scope objects can be large and
drown out targeted captures. Values are raw debugger values, so be careful when
sharing logs.
pausedDurationMs measures the client-observed time from receiving the matching
pause event until Debugger.resume completes. With --keep-paused, it is null
because resume is intentionally skipped.
If the target pauses somewhere else first, for example another debugger's
breakpoint or a debugger; statement, snapshot does not resume it by default.
It warns once, waits for Debugger.resumed, then continues waiting for its own
breakpoint within the remaining timeout. Use --fail-on-unmatched-pause when a
strict immediate error is preferred.
For Cloud Foundry targets, replace --port with --region/--org/--space/--app (and optionally --cf-timeout <seconds> for the tunnel).
📡 cf-inspector log
Set a non-pausing logpoint and stream the evaluated expression each time the line executes. Safe for production traffic — the inspectee never pauses.
# Stream user IDs hitting handler.ts:42 for 30 seconds
cf-inspector log \
--port 9229 \
--at src/handler.ts:42 \
--expr 'JSON.stringify({ user: req.user, body: req.body })' \
--duration 30Output is JSON Lines on stdout (one event per line) plus a summary trailer on stderr:
{"ts":"2026-04-29T...","at":"src/handler.ts:42","value":"{\"user\":\"alice\",\"body\":{}}"}
{"ts":"2026-04-29T...","at":"src/handler.ts:42","value":"{\"user\":\"bob\",\"body\":{}}"}
// stderr:
{"stopped":"duration","emitted":2}When the user expression throws, the event is emitted with error instead of value so the stream never silently gaps:
{"ts":"…","at":"src/handler.ts:42","error":"Cannot read properties of undefined (reading 'user')"}| Flag | Description |
| --- | --- |
| --port <number> | Local port the inspector or tunnel listens on. Required unless --app/--region/--org/--space are all set |
| --at <file:line> | Required. Source location to log at |
| --expr <expression> | Required. JS expression to evaluate at each hit (wrapped in try/catch on the inspectee side) |
| --duration <seconds> | Stop streaming after N seconds (default: run until SIGINT) |
| --max-events <n> | Stop streaming after emitting N log events. The trailer reports stopped: "max-events" |
| --hit-count <n> | Start emitting once the line has been hit N or more times |
| --condition <expr> | Only log when this JS expression evaluates truthy on the inspectee. Composes with --hit-count via logical AND |
| --remote-root <value> | Optional path-mapping anchor (same DSL as snapshot) |
| --no-json | Print human-readable lines instead of JSON Lines |
🔁 cf-inspector watch
Stream a snapshot per breakpoint hit. The inspectee is paused briefly while
captures are evaluated, then resumed automatically; output is JSON Lines on
stdout with a trailer on stderr (same shape as log).
cf-inspector watch --port 9229 \
--bp src/handler.ts:42 \
--capture 'user.id, payload' \
--condition 'user.id !== "system"' \
--duration 30 \
--max-events 50Each event is a WatchEvent:
{"ts":"2026-04-29T...","at":"file:///app/src/handler.ts:42","hit":1,"reason":"other","hitBreakpoints":["..."],"captures":[{"expression":"user.id","value":"\"alice\""}]}
{"ts":"2026-04-29T...","at":"file:///app/src/handler.ts:42","hit":2,"reason":"other","hitBreakpoints":["..."],"captures":[{"expression":"user.id","value":"\"bob\""}]}
// stderr trailer:
{"stopped":"max-events","emitted":50}| Flag | Description |
| --- | --- |
| --port <number> | Local port the inspector or tunnel listens on |
| --bp <file:line> | Required. Source location to capture on (repeatable) |
| --capture <expr,…> | Top-level comma-separated expressions to evaluate per hit |
| --condition <expr> | Only emit hits where this expression evaluates truthy |
| --hit-count <n> | Start emitting once the line has been hit N or more times |
| --remote-root <value> | Path-mapping anchor (same DSL as snapshot) |
| --duration <seconds> | Stop streaming after N seconds (default: until SIGINT) |
| --max-events <n> | Stop streaming after emitting N events |
| --timeout <seconds> | How long to wait for the next hit before giving up (default: 30) |
| --max-value-length <chars> | Maximum characters per captured value before truncation |
| --stack-depth <n> | Walk this many call frames per hit (default: 1) |
| --stack-captures <expr,…> | Expressions to evaluate on each call frame |
| --include-scopes | Include expanded paused-frame scopes per hit |
| --no-json | Print human-readable lines instead of JSON Lines |
💥 cf-inspector exception
Pause on a thrown exception, capture the exception value plus the paused frame, then resume.
cf-inspector exception --port 9229 \
--type uncaught \
--capture 'this' \
--stack-depth 4 \
--stack-captures 'arguments[0]' \
--timeout 30Result is a SnapshotResult with an extra exception field:
{
"reason": "exception",
"hitBreakpoints": [],
"capturedAt": "2026-04-29T...",
"pausedDurationMs": 0.5,
"topFrame": {"functionName": "validate", "url": "...", "line": 42, "column": 5},
"exception": {"value": "{\"message\":\"missing field\",\"name\":\"Error\"}", "type": "object", "description": "missing field"},
"captures": [],
"stack": [...]
}| Flag | Description |
| --- | --- |
| --type <state> | Pause on which exceptions: uncaught (default), caught, or all |
| --capture <expr,…> | Top-level expressions to evaluate in the paused frame |
| --stack-depth <n> | Walk this many call frames (default: 1) |
| --stack-captures <expr,…> | Expressions to evaluate on each frame |
| --include-scopes | Include paused-frame scopes |
| --remote-root <value> | Path-mapping anchor (only used if you also wire snapshot helpers) |
| --timeout <seconds> | How long to wait for an exception (default: 30) |
| --max-value-length <chars> | Maximum characters per captured value before truncation |
| --keep-paused | Skip Debugger.resume after capture |
| --no-json | Print a human-readable summary instead of JSON |
🧮 cf-inspector eval
Evaluate one expression with Runtime.evaluate in the global scope and print the result. For paused-frame values, use snapshot --capture or the programmatic evaluateOnFrame(...) API.
cf-inspector eval --port 9229 --expr 'process.uptime()'📜 cf-inspector list-scripts
Print every script the V8 instance knows about (useful for debugging path-mapping issues).
cf-inspector list-scripts --port 9229🔗 cf-inspector attach
Connect, fetch the runtime version, print it, disconnect. Useful as a smoke-test that the tunnel is healthy.
cf-inspector attach --port 9229🧑💻 Programmatic Usage
import {
connectInspector,
setBreakpoint,
waitForPause,
captureSnapshot,
evaluateOnFrame,
resume,
} from "@saptools/cf-inspector";
const session = await connectInspector({ port: 9229 });
const bp = await setBreakpoint(session, {
file: "src/handler.ts",
line: 42,
});
const pause = await waitForPause(session, { timeoutMs: 30_000 });
const snapshot = await captureSnapshot(session, pause, {
captures: ["this.user"],
maxValueLength: 4096,
});
const topFrame = pause.callFrames[0];
if (topFrame === undefined) {
throw new Error("Breakpoint paused without a call frame");
}
const customValue = await evaluateOnFrame(session, topFrame.callFrameId, "this.user");
await resume(session);
await session.dispose();
console.log({ bp, snapshot, customValue });| Export | Description |
| --- | --- |
| connectInspector(options) | Open a CDP WebSocket session against a port |
| setBreakpoint(session, location) | Set a breakpoint by file/line + optional remote root and hitCount |
| removeBreakpoint(session, id) | Remove a breakpoint by id |
| setPauseOnExceptions(session, state) | Configure exception pause state: none / uncaught / caught / all |
| waitForPause(session, options) | Resolve when the next Debugger.paused event fires; supports pauseReasons allow-list |
| captureSnapshot(session, pause, options) | Build a structured snapshot of the paused frame. Pass includeScopes: true to expand scopes, stackDepth + stackCaptures for multi-frame walks, or maxValueLength to override the default captured value limit |
| captureException(session, pause, maxValueLength) | Materialize the exception attached to a Debugger.paused event |
| walkStack(session, frames, options) | Walk a call stack and evaluate per-frame expressions |
| evaluateOnFrame(session, frameId, expression) | Evaluate in a paused frame |
| evaluateGlobal(session, expression) | Evaluate against the global Runtime |
| listScripts(session) | Return the scripts the V8 instance knows about |
| resume(session) | Resume execution |
| streamLogpoint(session, options) | Stream a non-pausing logpoint until duration / signal / max-events / transport-close |
| buildLogpointCondition(sentinel, expression, options?) | Build the CDP condition string for a logpoint with optional predicate / hit-count gates |
| buildHitCountedCondition(hitCount, key, userCondition?) | Build the hit-count gate used by setBreakpoint({ hitCount }) |
| parseRemoteRoot(value) | Parse a literal/regex remote-root setting |
| buildBreakpointUrlRegex(input) | Build a CDP urlRegex for a file path |
| CfInspectorError | Rich error class with typed code |
| Code | When |
| --- | --- |
| INVALID_ARGUMENT | A numeric flag (--port, --timeout, --duration, …) is not a positive integer |
| INVALID_BREAKPOINT | --bp / --at is not in file:line form, or line is not a positive integer |
| INVALID_REMOTE_ROOT | --remote-root regex did not compile |
| INVALID_EXPRESSION | --condition or --expr failed to parse on V8 (Runtime.compileScript reported a SyntaxError) — fast-fail before the breakpoint is set |
| INVALID_HIT_COUNT | --hit-count is not a positive integer |
| INVALID_PAUSE_TYPE | cf-inspector exception --type is not one of uncaught / caught / all |
| BREAKPOINT_DID_NOT_BIND | Reserved: a breakpoint resolved to no scripts. Currently surfaced as a stderr warning only — see BreakpointHandle.resolvedLocations for programmatic detection |
| INSPECTOR_DISCOVERY_FAILED | /json/list did not return a usable WebSocket URL |
| INSPECTOR_CONNECTION_FAILED | WebSocket handshake failed, or the connection closed mid-request |
| CDP_REQUEST_FAILED | A CDP method returned an error result, timed out, or failed to send |
| BREAKPOINT_NOT_HIT | The breakpoint did not hit before the timeout elapsed |
| UNRELATED_PAUSE | The target paused somewhere else and --fail-on-unmatched-pause was enabled |
| UNRELATED_PAUSE_TIMEOUT | The target stayed paused somewhere else until the snapshot timeout elapsed |
| EVALUATION_FAILED | Reserved for future use — current evaluation paths surface remote exceptions inline via CapturedExpression.error instead of throwing |
| MISSING_TARGET | Neither --port nor a complete CF target (--region/--org/--space/--app) was provided |
| ABORTED | Reserved for future use by long-running streams when an AbortSignal fires |
🔭 How it works
┌──────────────────────┐ 1. GET http://127.0.0.1:<port>/json/list
│ cf-inspector │ 2. Open ws:// debugger URL
│ snapshot --bp X:Y │ ─►3. Debugger.enable + Runtime.enable
└──────────────────────┘ 4. Debugger.setBreakpointByUrl({ urlRegex, lineNumber: Y - 1 })
│ 5. Wait for `Debugger.paused`
▼ 6. Debugger.evaluateOnCallFrame(...) for each --capture expression
JSON snapshot 7. Runtime.getProperties(scopeChain[i].object.objectId) when --include-scopes is set
8. Debugger.resume (unless --keep-paused)Path mapping uses CDP's first-class urlRegex:
| --remote-root | Resulting urlRegex (line 42 of src/handler.ts) |
| --- | --- |
| omitted | (?:^|/)src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$ |
| /home/vcap/app (literal) | ^file:///home/vcap/app/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$ |
| regex:^/example-root-.*$ | ^file:///example-root-.*/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$ |
| regex:^/(home/vcap/app\|example-root-.*)$ | ^file:///(home/vcap/app\|example-root-.*)/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$ |
.ts ↔ .js is folded into the regex automatically because Node's V8 inspector normally serves both the source-mapped TypeScript URL and the runtime JavaScript URL — matching either is correct.
⚙️ Composing with cf-debugger
If --port is omitted but --region/--org/--space/--app are given, the CLI internally calls startDebugger(...) from @saptools/cf-debugger, attaches over the SSH tunnel, and disposes the tunnel on exit. You get the same one-shot UX whether the target is local or in CF.
cf-inspector snapshot \
--region eu10 --org my-org --space dev --app my-srv \
--bp src/handler.ts:42 \
--capture 'req.url, this.user'🌐 Related
- 🐛
@saptools/cf-debugger— opens the SSH inspector tunnel - ☁️
@saptools/cf-sync— snapshot CF topology + DB bindings into JSON - 🗂️ saptools monorepo — the full toolbox
👨💻 Author
dongtran ✨
📄 License
MIT
Made with ❤️ to make your work life easier!
