@saptools/cf-live-trace 0.1.9 → 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 CHANGED
@@ -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
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
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() { ((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)); }',
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 () => {
@@ -2395,6 +2416,12 @@ function registerSessionCommands(program) {
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
  });
@@ -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
- const duration = createDurationStopWaiter(options.limits.durationMs);
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.cleanup();
2785
+ duration?.cleanup();
2599
2786
  eventLimit.cleanup();
2600
2787
  runtimeError.cleanup();
2601
2788
  await session.stop({ uninstallRuntimeHook: options.uninstallOnExit, reason: failed ? "error" : stopReason });