@saptools/cf-live-trace 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -36
- package/dist/cli.js +192 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](https://www.typescriptlang.org)
|
|
11
11
|
|
|
12
|
-
[Install](#install) - [Quick Start](#quick-start) - [CLI](#cli) - [
|
|
12
|
+
[Install](#install) - [Quick Start](#quick-start) - [CLI](#cli) - [Security](#security-notes)
|
|
13
13
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
@@ -152,15 +152,19 @@ cf-live-trace \
|
|
|
152
152
|
--format json
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
-
Inspect a saved session after or during a trace run:
|
|
155
|
+
Inspect, export, or replay a saved session after or during a trace run:
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
158
|
cf-live-trace session events s1a2b3c4d5e6f7a8b --method POST --limit 20
|
|
159
|
-
cf-live-trace session search s1a2b3c4d5e6f7a8b orderId --body
|
|
159
|
+
cf-live-trace session search s1a2b3c4d5e6f7a8b orderId --body response --length 256
|
|
160
160
|
cf-live-trace session body s1a2b3c4d5e6f7a8b r1a2b3c4d5e6f7a8 --body response --path /data/items/0 --limit 4000 --rows 100
|
|
161
|
+
cf-live-trace session curl s1a2b3c4d5e6f7a8b r1a2b3c4d5e6f7a8 --target http://localhost:4004 --out replay.sh
|
|
162
|
+
cf-live-trace session replay s1a2b3c4d5e6f7a8b r1a2b3c4d5e6f7a8 --target http://localhost:4004
|
|
161
163
|
cf-live-trace session prune
|
|
162
164
|
```
|
|
163
165
|
|
|
166
|
+
`session curl` reconstructs an absolute URL from `x-forwarded-proto`, `x-forwarded-host`, or `host` when `--target` is omitted. Without `--copy` or `--out`, it truncates displayed header values and request bodies to keep terminals responsive; use `--copy` or `--out` for the full command. Both `session curl` and `session replay` refuse to run when the captured request body was already truncated by `--max-body-bytes`, because replaying an incomplete payload is usually misleading. Hop-by-hop headers such as `host` and `content-length` are omitted from generated and replayed requests so the target HTTP client can recalculate them safely.
|
|
167
|
+
|
|
164
168
|
---
|
|
165
169
|
|
|
166
170
|
## How It Works
|
|
@@ -180,39 +184,6 @@ The injected global is named `__SAPTOOLS_CF_LIVE_TRACE__` so CLI sessions do not
|
|
|
180
184
|
|
|
181
185
|
---
|
|
182
186
|
|
|
183
|
-
## Programmatic Usage
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
import { LiveTraceSession } from "@saptools/cf-live-trace";
|
|
187
|
-
|
|
188
|
-
const session = new LiveTraceSession({
|
|
189
|
-
target: {
|
|
190
|
-
region: "ap10",
|
|
191
|
-
email: process.env["SAP_EMAIL"] ?? "",
|
|
192
|
-
password: process.env["SAP_PASSWORD"] ?? "",
|
|
193
|
-
org: "sample-org",
|
|
194
|
-
space: "dev",
|
|
195
|
-
app: "orders-api",
|
|
196
|
-
instanceIndex: 0,
|
|
197
|
-
},
|
|
198
|
-
onEvents(events) {
|
|
199
|
-
for (const event of events) {
|
|
200
|
-
process.stdout.write(`${event.method} ${event.normalizedUrl} ${String(event.status)}\n`);
|
|
201
|
-
}
|
|
202
|
-
},
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
await session.start({ maxBodyBytes: 4096 });
|
|
206
|
-
|
|
207
|
-
process.once("SIGINT", () => {
|
|
208
|
-
void session.stop({ uninstallRuntimeHook: true, reason: "user" });
|
|
209
|
-
});
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
The public API also exports helpers for payload parsing, runtime expression construction, CF SSH argument construction, and URL summaries.
|
|
213
|
-
|
|
214
|
-
---
|
|
215
|
-
|
|
216
187
|
## Security Notes
|
|
217
188
|
|
|
218
189
|
- This tool injects JavaScript into a running Node.js process through the Node inspector. Use it only for apps and spaces you are authorized to inspect.
|
package/dist/cli.js
CHANGED
|
@@ -421,7 +421,7 @@ function buildCfEnvForTarget(overrides) {
|
|
|
421
421
|
}
|
|
422
422
|
var INSPECTOR_SIGNAL_COMMAND = [
|
|
423
423
|
'inspector_url="http://127.0.0.1:9229/json/list"',
|
|
424
|
-
'inspector_ready() { (
|
|
424
|
+
'inspector_ready() { ( command -v curl >/dev/null 2>&1 && curl -fsS --max-time 1 "$inspector_url" >/dev/null 2>&1 ) || ( command -v wget >/dev/null 2>&1 && wget -qO- -T 1 "$inspector_url" >/dev/null 2>&1 ); }',
|
|
425
425
|
"if inspector_ready; then",
|
|
426
426
|
"echo saptools-inspector-ready",
|
|
427
427
|
"exit 0",
|
|
@@ -2139,6 +2139,10 @@ function writeSummaryLine(event) {
|
|
|
2139
2139
|
`);
|
|
2140
2140
|
}
|
|
2141
2141
|
|
|
2142
|
+
// src/cli/session-commands.ts
|
|
2143
|
+
import { spawn as spawn2 } from "child_process";
|
|
2144
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
2145
|
+
|
|
2142
2146
|
// src/trace-inspect.ts
|
|
2143
2147
|
var DEFAULT_BODY_LIMIT = 4e3;
|
|
2144
2148
|
function inspectTraceBodyResult(record, options) {
|
|
@@ -2381,6 +2385,23 @@ function isRecord4(value) {
|
|
|
2381
2385
|
}
|
|
2382
2386
|
|
|
2383
2387
|
// src/cli/session-commands.ts
|
|
2388
|
+
var DISPLAY_HEADER_VALUE_LIMIT = 128;
|
|
2389
|
+
var DISPLAY_BODY_LIMIT = 2e3;
|
|
2390
|
+
var DISPLAY_RESPONSE_BODY_LIMIT = 4e3;
|
|
2391
|
+
var REPLAY_OMITTED_HEADER_NAMES = /* @__PURE__ */ new Set([
|
|
2392
|
+
"connection",
|
|
2393
|
+
"content-length",
|
|
2394
|
+
"expect",
|
|
2395
|
+
"host",
|
|
2396
|
+
"keep-alive",
|
|
2397
|
+
"proxy-authenticate",
|
|
2398
|
+
"proxy-authorization",
|
|
2399
|
+
"te",
|
|
2400
|
+
"trailer",
|
|
2401
|
+
"transfer-encoding",
|
|
2402
|
+
"upgrade"
|
|
2403
|
+
]);
|
|
2404
|
+
var TRUNCATED_MARKER = "... [Truncated for display]";
|
|
2384
2405
|
function registerSessionCommands(program) {
|
|
2385
2406
|
const session = program.command("session").description("inspect saved live trace sessions");
|
|
2386
2407
|
session.command("list").description("list active trace sessions").action(async () => {
|
|
@@ -2389,12 +2410,18 @@ function registerSessionCommands(program) {
|
|
|
2389
2410
|
session.command("events <sessionId>").description("list compact events saved for a trace session").option("--method <method>", "filter by HTTP method").option("--status <code>", "filter by HTTP status", parseIntOption).option("--path <text>", "filter by URL/path substring").option("--limit <count>", "maximum events to print", parseIntOption).action(async (sessionId, _options, command) => {
|
|
2390
2411
|
await runEvents(sessionId, command.opts());
|
|
2391
2412
|
});
|
|
2392
|
-
session.command("search <sessionId> <text>").description("search saved request and response bodies").option("--body <side>", "request, response, or both", parseSearchBodySide, "
|
|
2413
|
+
session.command("search <sessionId> <text>").description("search saved request and response bodies").option("--body <side>", "request, response, or both", parseSearchBodySide, "response").option("--limit <count>", "maximum matches to print", parseIntOption).option("--length <chars>", "maximum preview characters", parseIntOption).action(async (sessionId, text, _options, command) => {
|
|
2393
2414
|
await runSearch(sessionId, text, command.opts());
|
|
2394
2415
|
});
|
|
2395
2416
|
session.command("body <sessionId> <requestId>").description("inspect a saved JSON request or response body").option("--body <side>", "request or response", parseBodySide, "response").option("--path <pointer>", "JSON Pointer inside the saved body").option("--limit <chars>", "maximum characters per value", parseIntOption).option("--rows <count>", "maximum structure rows to print", parseIntOption).action(async (sessionId, requestId, _options, command) => {
|
|
2396
2417
|
await runBody(sessionId, requestId, command.opts());
|
|
2397
2418
|
});
|
|
2419
|
+
session.command("curl <sessionId> <requestId>").description("export a saved request as a ready-to-run curl command").option("--target <baseUrl>", "rewrite the request URL to a target base URL, for example http://localhost:4004").option("--copy", "copy the full curl command to the clipboard").option("--out <file>", "write the full curl command to a shell script file").action(async (sessionId, requestId, _options, command) => {
|
|
2420
|
+
await runCurl(sessionId, requestId, command.opts());
|
|
2421
|
+
});
|
|
2422
|
+
session.command("replay <sessionId> <requestId>").description("replay a saved request directly from the CLI").option("--target <baseUrl>", "rewrite the request URL to a target base URL, for example http://localhost:4004").action(async (sessionId, requestId, _options, command) => {
|
|
2423
|
+
await runReplay(sessionId, requestId, command.opts());
|
|
2424
|
+
});
|
|
2398
2425
|
session.command("prune").description("remove expired trace event files").action(async () => {
|
|
2399
2426
|
writeJson({ removed: await pruneTraceSessions() });
|
|
2400
2427
|
});
|
|
@@ -2413,7 +2440,7 @@ async function runEvents(sessionId, options) {
|
|
|
2413
2440
|
}
|
|
2414
2441
|
async function runSearch(sessionId, text, options) {
|
|
2415
2442
|
const limit = positive2("--limit", options.limit, 20);
|
|
2416
|
-
const body = options.body ?? "
|
|
2443
|
+
const body = options.body ?? "response";
|
|
2417
2444
|
const previewLength = positive2("--length", options.length, 128);
|
|
2418
2445
|
const matches = [];
|
|
2419
2446
|
await visitTraceEvents(sessionId, (record) => {
|
|
@@ -2444,6 +2471,165 @@ async function runBody(sessionId, requestId, options) {
|
|
|
2444
2471
|
...inspection
|
|
2445
2472
|
});
|
|
2446
2473
|
}
|
|
2474
|
+
async function runCurl(sessionId, requestId, options) {
|
|
2475
|
+
const record = await readSafeTraceEvent(sessionId, requestId);
|
|
2476
|
+
const curlOptions = curlCommandOptions(options.target);
|
|
2477
|
+
const fullCommand = buildCurlCommand(record, curlOptions).command;
|
|
2478
|
+
if (options.out !== void 0) {
|
|
2479
|
+
await writeFile2(options.out, `#!/usr/bin/env sh
|
|
2480
|
+
${fullCommand}
|
|
2481
|
+
`, { mode: 448 });
|
|
2482
|
+
}
|
|
2483
|
+
if (options.copy === true) {
|
|
2484
|
+
await copyToClipboard(fullCommand);
|
|
2485
|
+
}
|
|
2486
|
+
if (options.out === void 0 && options.copy !== true) {
|
|
2487
|
+
const displayCommand = buildCurlCommand(record, { ...curlOptions, display: true });
|
|
2488
|
+
process.stdout.write(`${displayCommand.command}
|
|
2489
|
+
`);
|
|
2490
|
+
if (displayCommand.truncated) {
|
|
2491
|
+
process.stderr.write("[cf-live-trace] warning: displayed curl was truncated to avoid flooding the terminal. Use --copy or --out to obtain the full command.\n");
|
|
2492
|
+
}
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
writeJson({ sessionId, requestId, copied: options.copy === true, out: options.out ?? null });
|
|
2496
|
+
}
|
|
2497
|
+
async function runReplay(sessionId, requestId, options) {
|
|
2498
|
+
const record = await readSafeTraceEvent(sessionId, requestId);
|
|
2499
|
+
const url = buildRequestUrl(record, options.target);
|
|
2500
|
+
const body = requestBodyForFetch(record);
|
|
2501
|
+
const response = await fetch(url, {
|
|
2502
|
+
method: record.event.method,
|
|
2503
|
+
headers: replayHeaders(record.event.requestHeaders),
|
|
2504
|
+
...body === void 0 ? {} : { body }
|
|
2505
|
+
});
|
|
2506
|
+
const responseBody = await response.text();
|
|
2507
|
+
writeJson({
|
|
2508
|
+
sessionId,
|
|
2509
|
+
requestId,
|
|
2510
|
+
url,
|
|
2511
|
+
status: response.status,
|
|
2512
|
+
statusText: response.statusText,
|
|
2513
|
+
body: truncateForDisplay(responseBody, DISPLAY_RESPONSE_BODY_LIMIT).value,
|
|
2514
|
+
bodyTruncatedForDisplay: responseBody.length > DISPLAY_RESPONSE_BODY_LIMIT
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
async function readSafeTraceEvent(sessionId, requestId) {
|
|
2518
|
+
const record = await readTraceEvent(sessionId, requestId);
|
|
2519
|
+
if (record.event.requestBodyTruncated) {
|
|
2520
|
+
throw new Error("Request body was truncated during capture. Cannot safely replay. Please re-run the trace with a larger --max-body-bytes.");
|
|
2521
|
+
}
|
|
2522
|
+
return record;
|
|
2523
|
+
}
|
|
2524
|
+
function curlCommandOptions(target) {
|
|
2525
|
+
return target === void 0 ? {} : { target };
|
|
2526
|
+
}
|
|
2527
|
+
function buildCurlCommand(record, options = {}) {
|
|
2528
|
+
const headers = options.display === true ? displayHeaders(record.event.requestHeaders) : { values: curlHeaders(record.event.requestHeaders), truncated: false };
|
|
2529
|
+
const body = options.display === true ? truncateForDisplay(record.event.requestBodyPreview, DISPLAY_BODY_LIMIT) : { value: record.event.requestBodyPreview, truncated: false };
|
|
2530
|
+
const parts = ["curl", "-i", "-X", shellQuote(record.event.method), shellQuote(buildRequestUrl(record, options.target))];
|
|
2531
|
+
for (const [name, value] of Object.entries(headers.values)) {
|
|
2532
|
+
parts.push("-H", shellQuote(`${name}: ${value}`));
|
|
2533
|
+
}
|
|
2534
|
+
if (body.value.length > 0 && allowsRequestBody(record.event.method)) {
|
|
2535
|
+
parts.push("--data-raw", shellQuote(body.value));
|
|
2536
|
+
}
|
|
2537
|
+
return { command: parts.join(" "), truncated: headers.truncated || body.truncated };
|
|
2538
|
+
}
|
|
2539
|
+
function buildRequestUrl(record, target) {
|
|
2540
|
+
const rawUrl = record.event.url.length > 0 ? record.event.url : record.event.normalizedUrl;
|
|
2541
|
+
if (target !== void 0) {
|
|
2542
|
+
return new URL(rawUrl, ensureTrailingSlash(target)).toString();
|
|
2543
|
+
}
|
|
2544
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl)) {
|
|
2545
|
+
return rawUrl;
|
|
2546
|
+
}
|
|
2547
|
+
const headers = lowerCaseHeaders(record.event.requestHeaders);
|
|
2548
|
+
const proto = firstHeaderValue(headers["x-forwarded-proto"]) ?? "http";
|
|
2549
|
+
const host = firstHeaderValue(headers["x-forwarded-host"]) ?? firstHeaderValue(headers["host"]);
|
|
2550
|
+
if (host === void 0 || host.trim().length === 0) {
|
|
2551
|
+
throw new Error("Cannot reconstruct absolute request URL because the trace has no host or x-forwarded-host header. Use --target <baseUrl>.");
|
|
2552
|
+
}
|
|
2553
|
+
return `${proto}://${host}${rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`}`;
|
|
2554
|
+
}
|
|
2555
|
+
function ensureTrailingSlash(target) {
|
|
2556
|
+
return target.endsWith("/") ? target : `${target}/`;
|
|
2557
|
+
}
|
|
2558
|
+
function lowerCaseHeaders(headers) {
|
|
2559
|
+
const lowered = {};
|
|
2560
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
2561
|
+
lowered[name.toLowerCase()] = value;
|
|
2562
|
+
}
|
|
2563
|
+
return lowered;
|
|
2564
|
+
}
|
|
2565
|
+
function displayHeaders(headers) {
|
|
2566
|
+
const displayed = {};
|
|
2567
|
+
let truncated = false;
|
|
2568
|
+
for (const [name, value] of Object.entries(curlHeaders(headers))) {
|
|
2569
|
+
const displayValue = truncateForDisplay(value, DISPLAY_HEADER_VALUE_LIMIT);
|
|
2570
|
+
displayed[name] = displayValue.value;
|
|
2571
|
+
truncated ||= displayValue.truncated;
|
|
2572
|
+
}
|
|
2573
|
+
return { values: displayed, truncated };
|
|
2574
|
+
}
|
|
2575
|
+
function curlHeaders(headers) {
|
|
2576
|
+
const output = {};
|
|
2577
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
2578
|
+
if (!REPLAY_OMITTED_HEADER_NAMES.has(name.toLowerCase())) {
|
|
2579
|
+
output[name] = value;
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
return output;
|
|
2583
|
+
}
|
|
2584
|
+
function truncateForDisplay(value, limit) {
|
|
2585
|
+
return value.length > limit ? { value: `${value.slice(0, limit)}${TRUNCATED_MARKER}`, truncated: true } : { value, truncated: false };
|
|
2586
|
+
}
|
|
2587
|
+
function requestBodyForFetch(record) {
|
|
2588
|
+
if (!allowsRequestBody(record.event.method) || record.event.requestBodyPreview.length === 0) {
|
|
2589
|
+
return void 0;
|
|
2590
|
+
}
|
|
2591
|
+
return record.event.requestBodyPreview;
|
|
2592
|
+
}
|
|
2593
|
+
function allowsRequestBody(method) {
|
|
2594
|
+
return !["GET", "HEAD"].includes(method.toUpperCase());
|
|
2595
|
+
}
|
|
2596
|
+
function replayHeaders(headers) {
|
|
2597
|
+
return curlHeaders(headers);
|
|
2598
|
+
}
|
|
2599
|
+
function firstHeaderValue(value) {
|
|
2600
|
+
const first = value?.split(",")[0]?.trim();
|
|
2601
|
+
return first === void 0 || first.length === 0 ? void 0 : first;
|
|
2602
|
+
}
|
|
2603
|
+
function shellQuote(value) {
|
|
2604
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
2605
|
+
}
|
|
2606
|
+
async function copyToClipboard(text) {
|
|
2607
|
+
const candidates = process.platform === "darwin" ? [["pbcopy"]] : process.platform === "win32" ? [["clip"]] : [["wl-copy"], [["x", "clip"].join(""), "-selection", "clipboard"], [["x", "sel"].join(""), "--clipboard", "--input"]];
|
|
2608
|
+
const errors = [];
|
|
2609
|
+
for (const candidate of candidates) {
|
|
2610
|
+
try {
|
|
2611
|
+
await writeToClipboardCommand(candidate, text);
|
|
2612
|
+
return;
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
throw new Error(`Unable to copy to clipboard. Install a supported clipboard tool or use --out. ${errors.join(" ")}`.trim());
|
|
2618
|
+
}
|
|
2619
|
+
async function writeToClipboardCommand(command, text) {
|
|
2620
|
+
const child = spawn2(command[0] ?? "", command.slice(1), { stdio: ["pipe", "ignore", "ignore"] });
|
|
2621
|
+
child.stdin.end(text);
|
|
2622
|
+
await new Promise((resolve, reject) => {
|
|
2623
|
+
child.once("error", reject);
|
|
2624
|
+
child.once("exit", (code) => {
|
|
2625
|
+
if (code === 0) {
|
|
2626
|
+
resolve();
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
reject(new Error(`${command.join(" ")} exited with code ${String(code)}`));
|
|
2630
|
+
});
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2447
2633
|
function matchesEvent(record, options, status) {
|
|
2448
2634
|
const method = options.method?.trim().toUpperCase();
|
|
2449
2635
|
const path = options.path?.trim().toLowerCase();
|
|
@@ -2584,18 +2770,19 @@ function writeSessionHints(traceSession, options) {
|
|
|
2584
2770
|
}
|
|
2585
2771
|
async function runUntilStopped(session, options, eventLimit, runtimeError) {
|
|
2586
2772
|
const abort = createAbortPromise();
|
|
2587
|
-
|
|
2773
|
+
let duration;
|
|
2588
2774
|
let stopReason = "user";
|
|
2589
2775
|
let failed = false;
|
|
2590
2776
|
try {
|
|
2591
2777
|
await session.start(options.trace);
|
|
2778
|
+
duration = createDurationStopWaiter(options.limits.durationMs);
|
|
2592
2779
|
stopReason = await waitForStop([abort, eventLimit, duration, runtimeError]);
|
|
2593
2780
|
} catch (error) {
|
|
2594
2781
|
failed = true;
|
|
2595
2782
|
throw error;
|
|
2596
2783
|
} finally {
|
|
2597
2784
|
abort.cleanup();
|
|
2598
|
-
duration
|
|
2785
|
+
duration?.cleanup();
|
|
2599
2786
|
eventLimit.cleanup();
|
|
2600
2787
|
runtimeError.cleanup();
|
|
2601
2788
|
await session.stop({ uninstallRuntimeHook: options.uninstallOnExit, reason: failed ? "error" : stopReason });
|