simplemdg-dev-cli 1.4.1 → 2.0.4

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.
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { spawn } from "node:child_process";
3
+ import crypto from "node:crypto";
4
+ import net from "node:net";
3
5
  import nodeFs from "node:fs";
4
6
  import fs from "fs-extra";
5
7
  import chalk from "chalk";
@@ -1145,6 +1147,1288 @@ async function runLogsCommand(options) {
1145
1147
  childProcess.on("close", () => resolve());
1146
1148
  });
1147
1149
  }
1150
+ function parseWebSocketUrl(value) {
1151
+ const url = new URL(value);
1152
+ return {
1153
+ host: url.hostname,
1154
+ port: Number(url.port || 80),
1155
+ path: `${url.pathname}${url.search}`,
1156
+ };
1157
+ }
1158
+ async function getNodeInspectorWebSocketUrl(localPort) {
1159
+ const response = await fetch(`http://127.0.0.1:${localPort}/json/list`);
1160
+ if (!response.ok) {
1161
+ return undefined;
1162
+ }
1163
+ const targets = await response.json();
1164
+ return targets.find((target) => target.webSocketDebuggerUrl)?.webSocketDebuggerUrl;
1165
+ }
1166
+ async function waitForNodeInspectorWebSocketUrl(localPort, timeoutMs = 15000) {
1167
+ const startedAt = Date.now();
1168
+ let lastError;
1169
+ while (Date.now() - startedAt < timeoutMs) {
1170
+ try {
1171
+ const webSocketUrl = await getNodeInspectorWebSocketUrl(localPort);
1172
+ if (webSocketUrl) {
1173
+ return webSocketUrl;
1174
+ }
1175
+ }
1176
+ catch (error) {
1177
+ lastError = error;
1178
+ }
1179
+ await new Promise((resolve) => setTimeout(resolve, 500));
1180
+ }
1181
+ if (lastError instanceof Error) {
1182
+ console.log(chalk.gray(`Could not read inspector WebSocket yet: ${lastError.message}`));
1183
+ }
1184
+ return undefined;
1185
+ }
1186
+ function encodeWebSocketFrame(payload) {
1187
+ const payloadBuffer = Buffer.from(payload, "utf8");
1188
+ const maskKey = crypto.randomBytes(4);
1189
+ let header;
1190
+ if (payloadBuffer.length < 126) {
1191
+ header = Buffer.alloc(2);
1192
+ header[0] = 0x81;
1193
+ header[1] = 0x80 | payloadBuffer.length;
1194
+ }
1195
+ else if (payloadBuffer.length <= 0xffff) {
1196
+ header = Buffer.alloc(4);
1197
+ header[0] = 0x81;
1198
+ header[1] = 0x80 | 126;
1199
+ header.writeUInt16BE(payloadBuffer.length, 2);
1200
+ }
1201
+ else {
1202
+ header = Buffer.alloc(10);
1203
+ header[0] = 0x81;
1204
+ header[1] = 0x80 | 127;
1205
+ header.writeBigUInt64BE(BigInt(payloadBuffer.length), 2);
1206
+ }
1207
+ const maskedPayload = Buffer.alloc(payloadBuffer.length);
1208
+ for (let index = 0; index < payloadBuffer.length; index += 1) {
1209
+ maskedPayload[index] = payloadBuffer[index] ^ maskKey[index % 4];
1210
+ }
1211
+ return Buffer.concat([header, maskKey, maskedPayload]);
1212
+ }
1213
+ function decodeWebSocketFrames(buffer) {
1214
+ const messages = [];
1215
+ let offset = 0;
1216
+ while (offset + 2 <= buffer.length) {
1217
+ const firstByte = buffer[offset];
1218
+ const secondByte = buffer[offset + 1];
1219
+ const opcode = firstByte & 0x0f;
1220
+ const isMasked = Boolean(secondByte & 0x80);
1221
+ let payloadLength = secondByte & 0x7f;
1222
+ let headerLength = 2;
1223
+ if (payloadLength === 126) {
1224
+ if (offset + 4 > buffer.length)
1225
+ break;
1226
+ payloadLength = buffer.readUInt16BE(offset + 2);
1227
+ headerLength = 4;
1228
+ }
1229
+ else if (payloadLength === 127) {
1230
+ if (offset + 10 > buffer.length)
1231
+ break;
1232
+ const longLength = buffer.readBigUInt64BE(offset + 2);
1233
+ if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) {
1234
+ throw new Error("WebSocket frame is too large");
1235
+ }
1236
+ payloadLength = Number(longLength);
1237
+ headerLength = 10;
1238
+ }
1239
+ const maskLength = isMasked ? 4 : 0;
1240
+ const frameLength = headerLength + maskLength + payloadLength;
1241
+ if (offset + frameLength > buffer.length) {
1242
+ break;
1243
+ }
1244
+ let payload = buffer.subarray(offset + headerLength + maskLength, offset + frameLength);
1245
+ if (isMasked) {
1246
+ const maskKey = buffer.subarray(offset + headerLength, offset + headerLength + 4);
1247
+ const unmaskedPayload = Buffer.alloc(payload.length);
1248
+ for (let index = 0; index < payload.length; index += 1) {
1249
+ unmaskedPayload[index] = payload[index] ^ maskKey[index % 4];
1250
+ }
1251
+ payload = unmaskedPayload;
1252
+ }
1253
+ if (opcode === 0x1) {
1254
+ messages.push(payload.toString("utf8"));
1255
+ }
1256
+ offset += frameLength;
1257
+ }
1258
+ return { messages, remaining: buffer.subarray(offset) };
1259
+ }
1260
+ async function sendInspectorEvaluateCommand(options) {
1261
+ const connection = parseWebSocketUrl(options.webSocketUrl);
1262
+ const timeoutMs = options.timeoutMs ?? 10000;
1263
+ const key = crypto.randomBytes(16).toString("base64");
1264
+ const request = [
1265
+ `GET ${connection.path} HTTP/1.1`,
1266
+ `Host: ${connection.host}:${connection.port}`,
1267
+ "Upgrade: websocket",
1268
+ "Connection: Upgrade",
1269
+ `Sec-WebSocket-Key: ${key}`,
1270
+ "Sec-WebSocket-Version: 13",
1271
+ "",
1272
+ "",
1273
+ ].join("\r\n");
1274
+ await new Promise((resolve, reject) => {
1275
+ const socket = net.createConnection({ host: connection.host, port: connection.port });
1276
+ const commandId = 1;
1277
+ let isHandshakeComplete = false;
1278
+ let handshakeBuffer = Buffer.alloc(0);
1279
+ let frameBuffer = Buffer.alloc(0);
1280
+ const timer = setTimeout(() => {
1281
+ socket.destroy();
1282
+ reject(new Error("Inspector Runtime.evaluate timed out"));
1283
+ }, timeoutMs);
1284
+ const cleanup = () => {
1285
+ clearTimeout(timer);
1286
+ socket.removeAllListeners();
1287
+ socket.end();
1288
+ socket.destroy();
1289
+ };
1290
+ socket.on("connect", () => {
1291
+ socket.write(request);
1292
+ });
1293
+ socket.on("error", (error) => {
1294
+ cleanup();
1295
+ reject(error);
1296
+ });
1297
+ socket.on("data", (chunk) => {
1298
+ try {
1299
+ if (!isHandshakeComplete) {
1300
+ handshakeBuffer = Buffer.concat([handshakeBuffer, chunk]);
1301
+ const headerEndIndex = handshakeBuffer.indexOf("\r\n\r\n");
1302
+ if (headerEndIndex < 0) {
1303
+ return;
1304
+ }
1305
+ const headerText = handshakeBuffer.subarray(0, headerEndIndex).toString("utf8");
1306
+ if (!/^HTTP\/1\.1 101/i.test(headerText)) {
1307
+ throw new Error(`Inspector WebSocket upgrade failed: ${headerText.split("\r\n")[0]}`);
1308
+ }
1309
+ isHandshakeComplete = true;
1310
+ const rest = handshakeBuffer.subarray(headerEndIndex + 4);
1311
+ frameBuffer = rest.length ? Buffer.concat([frameBuffer, rest]) : frameBuffer;
1312
+ const payload = JSON.stringify({
1313
+ id: commandId,
1314
+ method: "Runtime.evaluate",
1315
+ params: {
1316
+ expression: options.expression,
1317
+ awaitPromise: false,
1318
+ returnByValue: true,
1319
+ },
1320
+ });
1321
+ socket.write(encodeWebSocketFrame(payload));
1322
+ }
1323
+ else {
1324
+ frameBuffer = Buffer.concat([frameBuffer, chunk]);
1325
+ }
1326
+ const decoded = decodeWebSocketFrames(frameBuffer);
1327
+ frameBuffer = Buffer.from(decoded.remaining);
1328
+ for (const message of decoded.messages) {
1329
+ const parsed = JSON.parse(message);
1330
+ if (parsed.id === commandId) {
1331
+ cleanup();
1332
+ if (parsed.error) {
1333
+ reject(new Error(`Inspector Runtime.evaluate failed: ${JSON.stringify(parsed.error)}`));
1334
+ return;
1335
+ }
1336
+ resolve();
1337
+ return;
1338
+ }
1339
+ }
1340
+ }
1341
+ catch (error) {
1342
+ cleanup();
1343
+ reject(error);
1344
+ }
1345
+ });
1346
+ });
1347
+ }
1348
+ function extractJsonFromCloudFoundryLogLine(line) {
1349
+ const jsonStart = line.indexOf("{");
1350
+ if (jsonStart < 0)
1351
+ return undefined;
1352
+ try {
1353
+ return JSON.parse(line.slice(jsonStart));
1354
+ }
1355
+ catch {
1356
+ return undefined;
1357
+ }
1358
+ }
1359
+ function parseHttpWatchAppLine(line) {
1360
+ if (!line.includes("[APP/") || !line.includes("OUT"))
1361
+ return undefined;
1362
+ const payload = extractJsonFromCloudFoundryLogLine(line);
1363
+ if (!payload)
1364
+ return undefined;
1365
+ const msg = String(payload.msg ?? "");
1366
+ const methodMatch = msg.match(/\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+([^\s{]+)/);
1367
+ if (!methodMatch)
1368
+ return undefined;
1369
+ return {
1370
+ source: "APP",
1371
+ method: methodMatch[1],
1372
+ url: methodMatch[2],
1373
+ requestId: String(payload.request_id ?? payload.x_vcap_request_id ?? payload.x_request_id ?? ""),
1374
+ correlationId: String(payload.correlation_id ?? payload.x_correlationid ?? payload.x_correlation_id ?? ""),
1375
+ instance: String(payload.x_cf_instanceindex ?? payload.component_instance ?? ""),
1376
+ user: String(payload.remote_user ?? ""),
1377
+ tenant: String(payload.tenant_subdomain ?? payload.tenantid ?? payload.tenant_id ?? ""),
1378
+ userAgent: String(payload.user_agent ?? ""),
1379
+ contentLength: String(payload.content_length ?? payload.request_size_b ?? ""),
1380
+ authorization: String(payload.authorization ?? ""),
1381
+ message: msg,
1382
+ };
1383
+ }
1384
+ function parseKeyValueFromRouterLine(line, key) {
1385
+ const regex = new RegExp(`${key}:"([^"]*)"`);
1386
+ return line.match(regex)?.[1];
1387
+ }
1388
+ function parseHttpWatchRouterLine(line) {
1389
+ if (!line.includes("[RTR/") || !line.includes("HTTP/"))
1390
+ return undefined;
1391
+ const requestMatch = line.match(/"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+([^\s]+)\s+HTTP\/[^"]+"\s+(\d{3})\s+(\d+)\s+(\d+)/);
1392
+ if (!requestMatch)
1393
+ return undefined;
1394
+ const responseTimeSeconds = Number(line.match(/response_time:([0-9.]+)/)?.[1] ?? "");
1395
+ return {
1396
+ source: "RTR",
1397
+ method: requestMatch[1],
1398
+ url: requestMatch[2],
1399
+ status: requestMatch[3],
1400
+ requestBytes: requestMatch[4],
1401
+ responseBytes: requestMatch[5],
1402
+ durationMs: Number.isFinite(responseTimeSeconds) ? Math.round(responseTimeSeconds * 1000) : undefined,
1403
+ requestId: parseKeyValueFromRouterLine(line, "vcap_request_id"),
1404
+ correlationId: parseKeyValueFromRouterLine(line, "x_correlationid"),
1405
+ instance: line.match(/app_index:"([^"]*)"/)?.[1],
1406
+ tenant: parseKeyValueFromRouterLine(line, "tenantid"),
1407
+ userAgent: line.match(/"\s+"([^"]*)"\s+"[^\"]+:\d+"/)?.[1],
1408
+ };
1409
+ }
1410
+ function parseHttpWatchLine(line) {
1411
+ return parseHttpWatchAppLine(line) ?? parseHttpWatchRouterLine(line);
1412
+ }
1413
+ function formatHttpWatchEvent(appName, event) {
1414
+ const status = event.status ? chalk.green(String(event.status)) : chalk.gray("APP");
1415
+ const duration = event.durationMs !== undefined ? chalk.gray(`${event.durationMs}ms`) : "";
1416
+ const source = event.source === "RTR" ? chalk.magenta("RTR") : chalk.blue("APP");
1417
+ const requestId = event.requestId ? chalk.gray(` req=${event.requestId}`) : "";
1418
+ const instance = event.instance ? chalk.gray(` i=${event.instance}`) : "";
1419
+ const user = event.user ? chalk.gray(` user=${event.user}`) : "";
1420
+ const tenant = event.tenant ? chalk.gray(` tenant=${event.tenant}`) : "";
1421
+ const size = event.contentLength || event.requestBytes ? chalk.gray(` bytes=${event.contentLength || event.requestBytes}`) : "";
1422
+ const auth = event.authorization ? chalk.gray(` auth=${event.authorization}`) : "";
1423
+ return `${source} ${chalk.cyan(`[${appName}]`)} ${status} ${chalk.bold(event.method ?? "")} ${event.url ?? ""} ${duration}${instance}${user}${tenant}${size}${auth}${requestId}`.trim();
1424
+ }
1425
+ function printHttpWatchLine(appName, line, outputFile) {
1426
+ const event = parseHttpWatchLine(line);
1427
+ if (!event)
1428
+ return;
1429
+ const formatted = formatHttpWatchEvent(appName, event);
1430
+ console.log(formatted);
1431
+ if (outputFile) {
1432
+ const plain = formatted.replace(/\u001b\[[0-9;]*m/g, "");
1433
+ fs.appendFileSync(outputFile, `${plain}\n`, "utf8");
1434
+ }
1435
+ }
1436
+ async function resolveHttpWatchApps(options) {
1437
+ if (options.app?.trim()) {
1438
+ return uniqueValues(options.app.split(","));
1439
+ }
1440
+ return resolveRequestTraceApps({ app: options.app, refresh: options.refresh });
1441
+ }
1442
+ async function runHttpWatchForApps(options) {
1443
+ if (!options.appNames.length)
1444
+ throw new Error("No app selected for HTTP watch");
1445
+ if (options.out) {
1446
+ await fs.ensureDir(path.dirname(path.resolve(options.out)));
1447
+ await fs.writeFile(options.out, "", "utf8");
1448
+ }
1449
+ if (options.recent) {
1450
+ for (const appName of options.appNames) {
1451
+ const result = await runCommand("cf", ["logs", appName, "--recent"]);
1452
+ const text = `${result.stdout}\n${result.stderr}`;
1453
+ for (const line of text.split(/\r?\n/)) {
1454
+ printHttpWatchLine(appName, line, options.out);
1455
+ }
1456
+ }
1457
+ return;
1458
+ }
1459
+ const children = [];
1460
+ const stopAll = () => {
1461
+ for (const child of children) {
1462
+ if (!child.killed)
1463
+ child.kill();
1464
+ }
1465
+ };
1466
+ process.once("SIGINT", () => {
1467
+ console.log(chalk.gray("\nStopping HTTP watch..."));
1468
+ stopAll();
1469
+ process.exit(0);
1470
+ });
1471
+ for (const appName of options.appNames) {
1472
+ const child = spawn("cf", ["logs", appName], {
1473
+ stdio: ["ignore", "pipe", "pipe"],
1474
+ shell: false,
1475
+ windowsHide: true,
1476
+ });
1477
+ children.push(child);
1478
+ child.stdout.on("data", (chunk) => {
1479
+ for (const line of chunk.toString("utf8").split(/\r?\n/)) {
1480
+ printHttpWatchLine(appName, line, options.out);
1481
+ }
1482
+ });
1483
+ child.stderr.on("data", (chunk) => {
1484
+ for (const line of chunk.toString("utf8").split(/\r?\n/)) {
1485
+ printHttpWatchLine(appName, line, options.out);
1486
+ }
1487
+ });
1488
+ }
1489
+ console.log(chalk.green(`HTTP watch is watching ${options.appNames.length} app(s).`));
1490
+ console.log(chalk.gray("This uses existing CF/CDS/RTR logs. It shows method/path/status/user/tenant/size, but not full request body or full token."));
1491
+ console.log(chalk.gray("Press Ctrl+C to stop."));
1492
+ await new Promise((resolve) => {
1493
+ let closedCount = 0;
1494
+ for (const child of children) {
1495
+ child.on("close", () => {
1496
+ closedCount += 1;
1497
+ if (closedCount >= children.length)
1498
+ resolve();
1499
+ });
1500
+ }
1501
+ });
1502
+ }
1503
+ async function runHttpWatchCommand(options) {
1504
+ if (!options.skipOrgSelect) {
1505
+ await maybeSwitchCloudFoundryTargetForDebug({ app: options.app, refresh: options.refresh, skipOrgSelect: false });
1506
+ }
1507
+ await ensureCloudFoundrySessionFromCache();
1508
+ const appNames = await resolveHttpWatchApps(options);
1509
+ await runHttpWatchForApps({ appNames, recent: options.recent, out: options.out });
1510
+ }
1511
+ async function runRequestTraceDoctorCommand(options) {
1512
+ await maybeSwitchCloudFoundryTargetForDebug({
1513
+ app: options.app,
1514
+ refresh: options.refresh,
1515
+ instance: options.instance,
1516
+ process: options.process,
1517
+ localPort: options.localPort,
1518
+ remotePort: options.remotePort,
1519
+ skipOrgSelect: options.skipOrgSelect,
1520
+ });
1521
+ await ensureCloudFoundrySessionFromCache();
1522
+ const appNames = await resolveRequestTraceApps({ app: options.app, refresh: options.refresh });
1523
+ const instanceIndex = await selectDebugInstance({ instance: options.instance });
1524
+ for (const appName of appNames) {
1525
+ console.log(chalk.cyan(`\nRequest trace doctor for ${appName} instance ${instanceIndex}`));
1526
+ console.log(chalk.gray("Recent router/app HTTP traffic:"));
1527
+ const result = await runCommand("cf", ["logs", appName, "--recent"]);
1528
+ const text = `${result.stdout}\n${result.stderr}`;
1529
+ let count = 0;
1530
+ for (const line of text.split(/\r?\n/)) {
1531
+ const event = parseHttpWatchLine(line);
1532
+ if (event) {
1533
+ count += 1;
1534
+ console.log(formatHttpWatchEvent(appName, event));
1535
+ if (count >= 10)
1536
+ break;
1537
+ }
1538
+ }
1539
+ if (!count) {
1540
+ console.log(chalk.yellow("No recent HTTP traffic found in CF logs for this app."));
1541
+ }
1542
+ console.log(chalk.gray("\nRemote process list:"));
1543
+ const processList = await runCommand("cf", ["ssh", appName, "-i", instanceIndex, "-T", "-c", "ps -eo pid,args 2>/dev/null | head -n 40"]);
1544
+ if (processList.stdout)
1545
+ console.log(processList.stdout);
1546
+ if (processList.stderr)
1547
+ console.error(processList.stderr);
1548
+ }
1549
+ console.log(chalk.yellow("\nDoctor summary:"));
1550
+ console.log("- If HTTP traffic appears above, the app is receiving requests.");
1551
+ console.log("- Full body/token are not available from CF/CDS logs because they are intentionally omitted or masked.");
1552
+ console.log("- Use smdg cf http-watch for stable live tracking.");
1553
+ console.log("- Use deep request-trace only when you accept Inspector/preload limitations in dev/test.");
1554
+ }
1555
+ function buildRequestTraceInjectionExpression(options) {
1556
+ const traceOptions = JSON.stringify(options);
1557
+ const source = `(() => {
1558
+ const options = ${traceOptions};
1559
+ const globalKey = "__SMDG_NETWORK_SPY__";
1560
+
1561
+ const state = globalThis[globalKey] || {
1562
+ installed: false,
1563
+ requestSeq: 0,
1564
+ options,
1565
+ patchedRequests: new WeakSet(),
1566
+ patchedResponses: new WeakSet(),
1567
+ patchedServers: new WeakSet(),
1568
+ activeRequests: new WeakMap(),
1569
+ };
1570
+
1571
+ state.options = options;
1572
+ globalThis[globalKey] = state;
1573
+
1574
+ function write(event) {
1575
+ try {
1576
+ console.log("SMDG_REQUEST_TRACE " + JSON.stringify(event));
1577
+ } catch (error) {
1578
+ console.log("SMDG_REQUEST_TRACE " + JSON.stringify({
1579
+ type: "smdg-request-trace-error",
1580
+ app: options.appName,
1581
+ message: error && error.message ? error.message : String(error),
1582
+ }));
1583
+ }
1584
+ }
1585
+
1586
+ function currentOptions() {
1587
+ return globalThis[globalKey] && globalThis[globalKey].options ? globalThis[globalKey].options : options;
1588
+ }
1589
+
1590
+ function shouldCaptureBody() {
1591
+ const mode = currentOptions().mode;
1592
+ return mode === "body" || mode === "response";
1593
+ }
1594
+
1595
+ function shouldCaptureResponse() {
1596
+ return currentOptions().mode === "response";
1597
+ }
1598
+
1599
+ function maxBodyBytes() {
1600
+ return Number(currentOptions().maxBodyBytes || 20000);
1601
+ }
1602
+
1603
+ function maskAuthorization(value) {
1604
+ if (!value) return undefined;
1605
+ const authMode = currentOptions().authMode;
1606
+ if (authMode === "omit") return undefined;
1607
+ if (authMode === "full") return String(value);
1608
+ const text = String(value);
1609
+ return text.length <= 24 ? "***" : text.slice(0, 16) + "..." + text.slice(-8);
1610
+ }
1611
+
1612
+ function normalizeHeaders(headers) {
1613
+ if (currentOptions().mode === "path") return undefined;
1614
+ const output = {};
1615
+ for (const [key, value] of Object.entries(headers || {})) {
1616
+ const lower = key.toLowerCase();
1617
+ if (lower === "authorization") {
1618
+ const auth = maskAuthorization(value);
1619
+ if (auth !== undefined) output[key] = auth;
1620
+ continue;
1621
+ }
1622
+ if (lower === "cookie" || lower === "set-cookie") {
1623
+ output[key] = "***";
1624
+ continue;
1625
+ }
1626
+ output[key] = value;
1627
+ }
1628
+ return output;
1629
+ }
1630
+
1631
+ function appendChunk(record, chunk) {
1632
+ if (!chunk || !shouldCaptureBody()) return;
1633
+ try {
1634
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
1635
+ record.requestBytes += buffer.length;
1636
+ const currentBytes = record.requestChunks.reduce((sum, item) => sum + item.length, 0);
1637
+ const limit = maxBodyBytes();
1638
+ if (currentBytes < limit) {
1639
+ record.requestChunks.push(buffer.subarray(0, Math.max(0, limit - currentBytes)));
1640
+ }
1641
+ } catch {}
1642
+ }
1643
+
1644
+ function appendResponseChunk(record, chunk) {
1645
+ if (!chunk || !shouldCaptureResponse()) return;
1646
+ try {
1647
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
1648
+ record.responseBytes += buffer.length;
1649
+ const currentBytes = record.responseChunks.reduce((sum, item) => sum + item.length, 0);
1650
+ const limit = maxBodyBytes();
1651
+ if (currentBytes < limit) {
1652
+ record.responseChunks.push(buffer.subarray(0, Math.max(0, limit - currentBytes)));
1653
+ }
1654
+ } catch {}
1655
+ }
1656
+
1657
+ function chunksToText(chunks) {
1658
+ try {
1659
+ if (!chunks || !chunks.length) return undefined;
1660
+ return Buffer.concat(chunks).toString("utf8");
1661
+ } catch {
1662
+ return undefined;
1663
+ }
1664
+ }
1665
+
1666
+ function tryParseContent(text, headers) {
1667
+ if (text === undefined) return undefined;
1668
+ if (!currentOptions().parseBodyJson) return text;
1669
+ const contentType = String((headers && (headers["content-type"] || headers["Content-Type"])) || "");
1670
+ if (contentType.includes("application/json") || /^[\\s]*[\\{\\[]/.test(text)) {
1671
+ try { return JSON.parse(text); } catch { return text; }
1672
+ }
1673
+ if (contentType.includes("application/x-www-form-urlencoded")) {
1674
+ try { return Object.fromEntries(new URLSearchParams(text)); } catch { return text; }
1675
+ }
1676
+ return text;
1677
+ }
1678
+
1679
+ function getRequestUrl(req) {
1680
+ return req.originalUrl || req.url || req.path || "";
1681
+ }
1682
+
1683
+ function patchRequestAndResponse(req, res, source) {
1684
+ if (!req || !res || state.patchedRequests.has(req)) return false;
1685
+
1686
+ state.patchedRequests.add(req);
1687
+ const record = {
1688
+ id: ++state.requestSeq,
1689
+ source,
1690
+ startedAt: Date.now(),
1691
+ requestChunks: [],
1692
+ responseChunks: [],
1693
+ requestBytes: 0,
1694
+ responseBytes: 0,
1695
+ };
1696
+ state.activeRequests.set(req, record);
1697
+
1698
+ try {
1699
+ if (!req.__SMDG_NETWORK_SPY_PUSH_PATCHED__) {
1700
+ const originalPush = req.push;
1701
+ if (typeof originalPush === "function") {
1702
+ req.push = function smdgNetworkTraceRequestPush(chunk, encoding) {
1703
+ appendChunk(record, chunk);
1704
+ return originalPush.call(this, chunk, encoding);
1705
+ };
1706
+ Object.defineProperty(req, "__SMDG_NETWORK_SPY_PUSH_PATCHED__", { value: true, enumerable: false });
1707
+ }
1708
+ }
1709
+ } catch {}
1710
+
1711
+ try {
1712
+ const originalEmit = req.emit;
1713
+ if (typeof originalEmit === "function" && !req.__SMDG_NETWORK_SPY_EMIT_PATCHED__) {
1714
+ req.emit = function smdgNetworkTraceRequestEmit(eventName, chunk, ...args) {
1715
+ if (eventName === "data") appendChunk(record, chunk);
1716
+ return originalEmit.call(this, eventName, chunk, ...args);
1717
+ };
1718
+ Object.defineProperty(req, "__SMDG_NETWORK_SPY_EMIT_PATCHED__", { value: true, enumerable: false });
1719
+ }
1720
+ } catch {}
1721
+
1722
+ try {
1723
+ if (!state.patchedResponses.has(res)) {
1724
+ state.patchedResponses.add(res);
1725
+ const originalWrite = res.write;
1726
+ const originalEnd = res.end;
1727
+
1728
+ if (typeof originalWrite === "function") {
1729
+ res.write = function smdgNetworkTraceResponseWrite(chunk, ...args) {
1730
+ appendResponseChunk(record, chunk);
1731
+ return originalWrite.call(this, chunk, ...args);
1732
+ };
1733
+ }
1734
+
1735
+ if (typeof originalEnd === "function") {
1736
+ res.end = function smdgNetworkTraceResponseEnd(chunk, ...args) {
1737
+ appendResponseChunk(record, chunk);
1738
+ return originalEnd.call(this, chunk, ...args);
1739
+ };
1740
+ }
1741
+ }
1742
+ } catch {}
1743
+
1744
+ const finish = () => {
1745
+ try {
1746
+ const requestBodyText = chunksToText(record.requestChunks);
1747
+ const responseBodyText = chunksToText(record.responseChunks);
1748
+ const headers = req.headers || {};
1749
+ const event = {
1750
+ type: "smdg-request-trace",
1751
+ app: currentOptions().appName,
1752
+ source: record.source,
1753
+ id: record.id,
1754
+ timestamp: new Date(record.startedAt).toISOString(),
1755
+ method: req.method,
1756
+ url: getRequestUrl(req),
1757
+ status: res.statusCode,
1758
+ durationMs: Date.now() - record.startedAt,
1759
+ requestBytes: record.requestBytes,
1760
+ responseBytes: record.responseBytes,
1761
+ headers: normalizeHeaders(headers),
1762
+ body: shouldCaptureBody() ? tryParseContent(requestBodyText, headers) : undefined,
1763
+ responseBody: shouldCaptureResponse() ? tryParseContent(responseBodyText, res.getHeaders ? res.getHeaders() : {}) : undefined,
1764
+ };
1765
+ write(event);
1766
+ } catch (error) {
1767
+ write({
1768
+ type: "smdg-request-trace-error",
1769
+ app: currentOptions().appName,
1770
+ message: error && error.message ? error.message : String(error),
1771
+ });
1772
+ }
1773
+ };
1774
+
1775
+ if (typeof res.once === "function") {
1776
+ res.once("finish", finish);
1777
+ res.once("close", () => {
1778
+ if (!res.writableEnded) finish();
1779
+ });
1780
+ }
1781
+
1782
+ return true;
1783
+ }
1784
+
1785
+ function installDiagnosticsChannelHook() {
1786
+ try {
1787
+ const diagnostics = require("diagnostics_channel");
1788
+ if (!diagnostics || diagnostics.__SMDG_NETWORK_SPY_PATCHED__) return false;
1789
+ const requestStart = diagnostics.channel("http.server.request.start");
1790
+ requestStart.subscribe((message) => {
1791
+ const req = message && (message.request || message.req);
1792
+ const res = message && (message.response || message.res);
1793
+ patchRequestAndResponse(req, res, "diagnostics_channel:http.server.request.start");
1794
+ });
1795
+ Object.defineProperty(diagnostics, "__SMDG_NETWORK_SPY_PATCHED__", { value: true, enumerable: false });
1796
+ return true;
1797
+ } catch {
1798
+ return false;
1799
+ }
1800
+ }
1801
+
1802
+ function installServerEmitHook() {
1803
+ try {
1804
+ const http = require("http");
1805
+ const Server = http && http.Server;
1806
+ if (!Server || !Server.prototype || Server.prototype.__SMDG_NETWORK_SPY_EMIT_PATCHED__) return false;
1807
+ const originalEmit = Server.prototype.emit;
1808
+ Server.prototype.emit = function smdgNetworkTraceServerEmit(eventName, req, res, ...args) {
1809
+ if (eventName === "request") patchRequestAndResponse(req, res, "http.Server.emit");
1810
+ return originalEmit.call(this, eventName, req, res, ...args);
1811
+ };
1812
+ Object.defineProperty(Server.prototype, "__SMDG_NETWORK_SPY_EMIT_PATCHED__", { value: true, enumerable: false });
1813
+ return true;
1814
+ } catch {
1815
+ return false;
1816
+ }
1817
+ }
1818
+
1819
+ function installCreateServerHook(moduleName) {
1820
+ try {
1821
+ const mod = require(moduleName);
1822
+ if (!mod || mod.__SMDG_NETWORK_SPY_CREATE_SERVER_PATCHED__) return false;
1823
+ const originalCreateServer = mod.createServer;
1824
+ if (typeof originalCreateServer !== "function") return false;
1825
+ mod.createServer = function smdgNetworkTraceCreateServer(...args) {
1826
+ const server = originalCreateServer.apply(this, args);
1827
+ hookServer(server, moduleName + ".createServer");
1828
+ return server;
1829
+ };
1830
+ Object.defineProperty(mod, "__SMDG_NETWORK_SPY_CREATE_SERVER_PATCHED__", { value: true, enumerable: false });
1831
+ return true;
1832
+ } catch {
1833
+ return false;
1834
+ }
1835
+ }
1836
+
1837
+ function hookServer(server, source) {
1838
+ try {
1839
+ if (!server || state.patchedServers.has(server)) return false;
1840
+ if (typeof server.prependListener === "function") {
1841
+ server.prependListener("request", (req, res) => patchRequestAndResponse(req, res, source));
1842
+ state.patchedServers.add(server);
1843
+ return true;
1844
+ }
1845
+ } catch {}
1846
+ return false;
1847
+ }
1848
+
1849
+ function hookActiveServers() {
1850
+ let count = 0;
1851
+ try {
1852
+ const handles = typeof process._getActiveHandles === "function" ? process._getActiveHandles() : [];
1853
+ for (const handle of handles) {
1854
+ if (handle && typeof handle.on === "function" && typeof handle.address === "function") {
1855
+ if (hookServer(handle, "active-handle")) count += 1;
1856
+ }
1857
+ }
1858
+ } catch {}
1859
+ return count;
1860
+ }
1861
+
1862
+ const diagnosticsHooked = installDiagnosticsChannelHook();
1863
+ const serverEmitHooked = installServerEmitHook();
1864
+ const httpCreateHooked = installCreateServerHook("http");
1865
+ const httpsCreateHooked = installCreateServerHook("https");
1866
+ const activeServers = hookActiveServers();
1867
+
1868
+ state.installed = true;
1869
+ state.installedAt = state.installedAt || new Date().toISOString();
1870
+
1871
+ write({
1872
+ type: "smdg-request-trace-status",
1873
+ app: options.appName,
1874
+ status: "installed",
1875
+ engine: "network-trace-v4",
1876
+ diagnosticsHooked,
1877
+ serverEmitHooked,
1878
+ httpCreateHooked,
1879
+ httpsCreateHooked,
1880
+ activeServers,
1881
+ mode: options.mode,
1882
+ authMode: options.authMode,
1883
+ maxBodyBytes: options.maxBodyBytes,
1884
+ });
1885
+
1886
+ return "installed:network-trace-v4:" + activeServers;
1887
+ })();`;
1888
+ return source;
1889
+ }
1890
+ async function selectRequestTraceMode() {
1891
+ return searchableSelectChoice({
1892
+ message: "Select request trace mode",
1893
+ choices: [
1894
+ { title: "Path only", value: "path", description: "method, URL, status, duration" },
1895
+ { title: "Headers", value: "headers", description: "include request headers, mask sensitive values" },
1896
+ { title: "Headers + body", value: "body", description: "include request body up to a safe size limit" },
1897
+ { title: "Headers + body + response", value: "response", description: "include request body and response body" },
1898
+ ],
1899
+ allowCustomValue: false,
1900
+ });
1901
+ }
1902
+ async function selectRequestTraceAuthMode() {
1903
+ return searchableSelectChoice({
1904
+ message: "Authorization header handling",
1905
+ choices: [
1906
+ { title: "Mask token (recommended)", value: "mask" },
1907
+ { title: "Show full token (dev/test only)", value: "full" },
1908
+ { title: "Omit Authorization header", value: "omit" },
1909
+ ],
1910
+ allowCustomValue: false,
1911
+ });
1912
+ }
1913
+ async function selectRequestTraceDisplayOptions(options) {
1914
+ const headerPreset = await searchableSelectChoice({
1915
+ message: "Headers to display in terminal",
1916
+ choices: [
1917
+ { title: "Minimal headers", value: "minimal", description: "host, content-type, authorization, request/correlation ids" },
1918
+ { title: "Common debug headers", value: "common", description: "minimal + user-agent, origin, forwarded, CF/B3 headers" },
1919
+ { title: "All captured headers", value: "all", description: "large output" },
1920
+ { title: "Custom header list", value: "custom", description: "enter comma-separated headers" },
1921
+ ],
1922
+ allowCustomValue: false,
1923
+ });
1924
+ let headerNames = [];
1925
+ if (headerPreset === "minimal")
1926
+ headerNames = getMinimalTraceHeaderNames();
1927
+ if (headerPreset === "common")
1928
+ headerNames = getCommonTraceHeaderNames();
1929
+ if (headerPreset === "custom") {
1930
+ const response = await prompts({
1931
+ type: "text",
1932
+ name: "headers",
1933
+ message: "Header names to display",
1934
+ initial: "authorization,content-type,content-length,x-correlationid,x-vcap-request-id,tenantid,user-agent",
1935
+ validate: (value) => value.trim() ? true : "At least one header is required",
1936
+ });
1937
+ headerNames = String(response.headers ?? "").split(",").map((item) => item.trim()).filter(Boolean);
1938
+ }
1939
+ const parseResponse = await prompts({
1940
+ type: "select",
1941
+ name: "parseBodyJson",
1942
+ message: "Try parse request/response body as JSON when possible?",
1943
+ choices: [
1944
+ { title: "Yes, parse JSON/form body when possible", value: true },
1945
+ { title: "No, keep raw body string", value: false },
1946
+ ],
1947
+ initial: 0,
1948
+ });
1949
+ let outputFile = options.out;
1950
+ if (!outputFile) {
1951
+ const outResponse = await prompts({
1952
+ type: "select",
1953
+ name: "export",
1954
+ message: "Export captured trace events to JSONL file?",
1955
+ choices: [
1956
+ { title: "No", value: false },
1957
+ { title: "Yes", value: true },
1958
+ ],
1959
+ initial: 0,
1960
+ });
1961
+ if (outResponse.export) {
1962
+ const fileResponse = await prompts({
1963
+ type: "text",
1964
+ name: "file",
1965
+ message: "Trace output file",
1966
+ initial: `smdg-request-trace-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`,
1967
+ validate: (value) => value.trim() ? true : "Output file is required",
1968
+ });
1969
+ outputFile = String(fileResponse.file ?? "").trim();
1970
+ }
1971
+ }
1972
+ if (outputFile) {
1973
+ await fs.ensureDir(path.dirname(path.resolve(outputFile)));
1974
+ await fs.writeFile(outputFile, "", "utf8");
1975
+ console.log(chalk.green(`Trace events will be exported to ${path.resolve(outputFile)}`));
1976
+ }
1977
+ return {
1978
+ headerPreset,
1979
+ headerNames,
1980
+ parseBodyJson: Boolean(parseResponse.parseBodyJson),
1981
+ outputFile,
1982
+ };
1983
+ }
1984
+ async function resolveRequestTraceApps(options) {
1985
+ if (options.app?.trim()) {
1986
+ return uniqueValues(options.app.split(","));
1987
+ }
1988
+ const apps = await getAppsWithCache({ refresh: options.refresh, startBackgroundRefresh: !options.refresh });
1989
+ const selectedApps = [];
1990
+ while (true) {
1991
+ const appName = await searchableSelectChoice({
1992
+ message: selectedApps.length ? "Add another BTP app to trace, or finish" : "Search/select BTP app to trace",
1993
+ choices: [
1994
+ ...apps
1995
+ .filter((app) => !selectedApps.includes(app.name))
1996
+ .map((app) => ({
1997
+ title: [app.name, app.requestedState, app.routes].filter(Boolean).join(" | "),
1998
+ value: app.name,
1999
+ })),
2000
+ ...(selectedApps.length ? [{ title: "Done", value: "__DONE__" }] : []),
2001
+ ],
2002
+ validateCustomValue: validateRequired,
2003
+ customValueTitle: (value) => `Use typed app name: ${value}`,
2004
+ });
2005
+ if (appName === "__DONE__") {
2006
+ break;
2007
+ }
2008
+ selectedApps.push(appName);
2009
+ await rememberSelectedApp(appName);
2010
+ const moreResponse = await prompts({
2011
+ type: "select",
2012
+ name: "more",
2013
+ message: "Trace another app at the same time?",
2014
+ choices: [
2015
+ { title: "No, start tracing now", value: false },
2016
+ { title: "Yes, add another app", value: true },
2017
+ ],
2018
+ initial: 0,
2019
+ });
2020
+ if (!moreResponse.more) {
2021
+ break;
2022
+ }
2023
+ }
2024
+ return selectedApps;
2025
+ }
2026
+ function getMinimalTraceHeaderNames() {
2027
+ return [
2028
+ "host",
2029
+ "content-type",
2030
+ "content-length",
2031
+ "authorization",
2032
+ "x-correlation-id",
2033
+ "x-correlationid",
2034
+ "x-vcap-request-id",
2035
+ "tenantid",
2036
+ ];
2037
+ }
2038
+ function getCommonTraceHeaderNames() {
2039
+ return [
2040
+ ...getMinimalTraceHeaderNames(),
2041
+ "user-agent",
2042
+ "origin",
2043
+ "referer",
2044
+ "x-forwarded-for",
2045
+ "x-forwarded-host",
2046
+ "x-forwarded-path",
2047
+ "x-forwarded-proto",
2048
+ "x-cf-applicationid",
2049
+ "x-cf-instanceindex",
2050
+ "x-cf-true-client-ip",
2051
+ "x-b3-traceid",
2052
+ "x-b3-spanid",
2053
+ "b3",
2054
+ ];
2055
+ }
2056
+ function normalizeHeaderName(value) {
2057
+ return value.trim().toLowerCase();
2058
+ }
2059
+ function filterTraceHeaders(headers, display) {
2060
+ if (!headers || typeof headers !== "object")
2061
+ return undefined;
2062
+ const source = headers;
2063
+ if (display.headerPreset === "all")
2064
+ return source;
2065
+ const names = new Set(display.headerNames.map(normalizeHeaderName));
2066
+ const output = {};
2067
+ for (const [key, value] of Object.entries(source)) {
2068
+ if (names.has(normalizeHeaderName(key)))
2069
+ output[key] = value;
2070
+ }
2071
+ return output;
2072
+ }
2073
+ function stringifyTraceValue(value) {
2074
+ if (value === undefined || value === null)
2075
+ return "";
2076
+ if (typeof value === "string")
2077
+ return value;
2078
+ try {
2079
+ return JSON.stringify(value);
2080
+ }
2081
+ catch {
2082
+ return String(value);
2083
+ }
2084
+ }
2085
+ function traceEventMatchesFilters(event, filters) {
2086
+ if (filters.paused)
2087
+ return false;
2088
+ const method = String(event.method ?? "").toLowerCase();
2089
+ const url = String(event.url ?? "").toLowerCase();
2090
+ const status = String(event.status ?? "").toLowerCase();
2091
+ const body = stringifyTraceValue(event.body).toLowerCase();
2092
+ const responseBody = stringifyTraceValue(event.responseBody).toLowerCase();
2093
+ const all = stringifyTraceValue(event).toLowerCase();
2094
+ if (filters.method && method !== filters.method.toLowerCase())
2095
+ return false;
2096
+ if (filters.path && !url.includes(filters.path.toLowerCase()))
2097
+ return false;
2098
+ if (filters.status && !status.includes(filters.status.toLowerCase()))
2099
+ return false;
2100
+ if (filters.body && !body.includes(filters.body.toLowerCase()) && !responseBody.includes(filters.body.toLowerCase()))
2101
+ return false;
2102
+ if (filters.text && !all.includes(filters.text.toLowerCase()))
2103
+ return false;
2104
+ return true;
2105
+ }
2106
+ function buildPrintableTracePayload(event, display) {
2107
+ const output = {
2108
+ type: event.type,
2109
+ app: event.app,
2110
+ source: event.source,
2111
+ id: event.id,
2112
+ timestamp: event.timestamp,
2113
+ method: event.method,
2114
+ url: event.url,
2115
+ status: event.status,
2116
+ durationMs: event.durationMs,
2117
+ requestBytes: event.requestBytes,
2118
+ responseBytes: event.responseBytes,
2119
+ };
2120
+ const headers = filterTraceHeaders(event.headers, display);
2121
+ if (headers && Object.keys(headers).length > 0)
2122
+ output.headers = headers;
2123
+ if (event.body !== undefined)
2124
+ output.body = event.body;
2125
+ if (event.responseBody !== undefined)
2126
+ output.responseBody = event.responseBody;
2127
+ return output;
2128
+ }
2129
+ function writeTraceEventToFile(outputFile, event) {
2130
+ if (!outputFile)
2131
+ return;
2132
+ try {
2133
+ fs.appendFileSync(outputFile, `${JSON.stringify(event)}\n`, "utf8");
2134
+ }
2135
+ catch (error) {
2136
+ console.error(chalk.yellow(`Failed to write trace event to file: ${error instanceof Error ? error.message : String(error)}`));
2137
+ }
2138
+ }
2139
+ function printRequestTraceEvent(appName, payload, runtime) {
2140
+ const type = String(payload.type ?? "smdg-request-trace");
2141
+ if (type === "smdg-request-trace-status") {
2142
+ console.log(chalk.green(`[${appName}] ${String(payload.status ?? "trace-status")}`));
2143
+ console.log(chalk.gray(`engine=${String(payload.engine ?? "unknown")} activeServers=${String(payload.activeServers ?? "?")} mode=${String(payload.mode ?? "")}`));
2144
+ return;
2145
+ }
2146
+ if (type === "smdg-request-trace-error") {
2147
+ console.log(chalk.red(`[${appName}] trace error: ${String(payload.message ?? "unknown")}`));
2148
+ return;
2149
+ }
2150
+ runtime.events.push(payload);
2151
+ writeTraceEventToFile(runtime.display.outputFile, payload);
2152
+ if (!traceEventMatchesFilters(payload, runtime.filters))
2153
+ return;
2154
+ const time = String(payload.timestamp ?? new Date().toISOString());
2155
+ const method = String(payload.method ?? "");
2156
+ const url = String(payload.url ?? "");
2157
+ const status = String(payload.status ?? "");
2158
+ const duration = String(payload.durationMs ?? "");
2159
+ console.log(chalk.cyan(`\n[${time}] [${appName}] ${method} ${url} → ${status} ${duration}ms`));
2160
+ console.log(JSON.stringify(buildPrintableTracePayload(payload, runtime.display), null, 2));
2161
+ }
2162
+ function printRequestTraceLine(appName, line, runtime) {
2163
+ const marker = line.includes("SMDG_REQUEST_TRACE ") ? "SMDG_REQUEST_TRACE " : line.includes("SMDG_REQUEST_SPY ") ? "SMDG_REQUEST_SPY " : undefined;
2164
+ if (!marker)
2165
+ return;
2166
+ const markerIndex = line.indexOf(marker);
2167
+ const payloadText = line.slice(markerIndex + marker.length).trim();
2168
+ try {
2169
+ const payload = JSON.parse(payloadText);
2170
+ printRequestTraceEvent(appName, payload, runtime);
2171
+ }
2172
+ catch {
2173
+ console.log(`[${appName}] ${payloadText}`);
2174
+ }
2175
+ }
2176
+ function printTraceRuntimeHelp() {
2177
+ console.log(chalk.gray("\nRuntime trace commands:"));
2178
+ console.log(chalk.gray(" /method POST show only one method"));
2179
+ console.log(chalk.gray(" /path text show only URLs containing text"));
2180
+ console.log(chalk.gray(" /body text show only request/response body containing text"));
2181
+ console.log(chalk.gray(" /status 500 show only status containing value"));
2182
+ console.log(chalk.gray(" /text value search anywhere in the event"));
2183
+ console.log(chalk.gray(" /headers a,b,c change displayed headers while running"));
2184
+ console.log(chalk.gray(" /headers all display all captured headers"));
2185
+ console.log(chalk.gray(" /clear clear active filters"));
2186
+ console.log(chalk.gray(" /show show active filters"));
2187
+ console.log(chalk.gray(" /replay print matching events already captured"));
2188
+ console.log(chalk.gray(" /pause or /resume pause/resume terminal display"));
2189
+ console.log(chalk.gray(" /help show this help"));
2190
+ }
2191
+ function applyTraceRuntimeCommand(input, runtime) {
2192
+ const trimmed = input.trim();
2193
+ if (!trimmed)
2194
+ return;
2195
+ if (!trimmed.startsWith("/")) {
2196
+ runtime.filters.text = trimmed;
2197
+ console.log(chalk.yellow(`Search text filter: ${trimmed}`));
2198
+ return;
2199
+ }
2200
+ const [commandRaw, ...restParts] = trimmed.slice(1).split(" ");
2201
+ const command = commandRaw.toLowerCase();
2202
+ const value = restParts.join(" ").trim();
2203
+ if (command === "method")
2204
+ runtime.filters.method = value || undefined;
2205
+ else if (command === "path")
2206
+ runtime.filters.path = value || undefined;
2207
+ else if (command === "body")
2208
+ runtime.filters.body = value || undefined;
2209
+ else if (command === "status")
2210
+ runtime.filters.status = value || undefined;
2211
+ else if (command === "text")
2212
+ runtime.filters.text = value || undefined;
2213
+ else if (command === "pause")
2214
+ runtime.filters.paused = true;
2215
+ else if (command === "resume")
2216
+ runtime.filters.paused = false;
2217
+ else if (command === "clear") {
2218
+ runtime.filters.method = undefined;
2219
+ runtime.filters.path = undefined;
2220
+ runtime.filters.body = undefined;
2221
+ runtime.filters.status = undefined;
2222
+ runtime.filters.text = undefined;
2223
+ runtime.filters.paused = false;
2224
+ }
2225
+ else if (command === "headers") {
2226
+ if (!value || value.toLowerCase() === "common") {
2227
+ runtime.display.headerPreset = "common";
2228
+ runtime.display.headerNames = getCommonTraceHeaderNames();
2229
+ }
2230
+ else if (value.toLowerCase() === "minimal") {
2231
+ runtime.display.headerPreset = "minimal";
2232
+ runtime.display.headerNames = getMinimalTraceHeaderNames();
2233
+ }
2234
+ else if (value.toLowerCase() === "all") {
2235
+ runtime.display.headerPreset = "all";
2236
+ runtime.display.headerNames = [];
2237
+ }
2238
+ else {
2239
+ runtime.display.headerPreset = "custom";
2240
+ runtime.display.headerNames = value.split(",").map((item) => item.trim()).filter(Boolean);
2241
+ }
2242
+ }
2243
+ else if (command === "show") {
2244
+ console.log(chalk.gray(JSON.stringify({ filters: runtime.filters, display: runtime.display, captured: runtime.events.length }, null, 2)));
2245
+ return;
2246
+ }
2247
+ else if (command === "replay") {
2248
+ console.log(chalk.gray(`Replaying ${runtime.events.length} captured event(s) with current filters...`));
2249
+ for (const event of runtime.events) {
2250
+ if (traceEventMatchesFilters(event, runtime.filters)) {
2251
+ const appName = String(event.app ?? "app");
2252
+ const time = String(event.timestamp ?? "");
2253
+ console.log(chalk.cyan(`\n[${time}] [${appName}] ${String(event.method ?? "")} ${String(event.url ?? "")} → ${String(event.status ?? "")} ${String(event.durationMs ?? "")}ms`));
2254
+ console.log(JSON.stringify(buildPrintableTracePayload(event, runtime.display), null, 2));
2255
+ }
2256
+ }
2257
+ return;
2258
+ }
2259
+ else if (command === "help" || command === "?") {
2260
+ printTraceRuntimeHelp();
2261
+ return;
2262
+ }
2263
+ else {
2264
+ console.log(chalk.yellow(`Unknown runtime command: ${command}`));
2265
+ printTraceRuntimeHelp();
2266
+ return;
2267
+ }
2268
+ console.log(chalk.yellow(`Trace runtime updated: ${trimmed}`));
2269
+ }
2270
+ function attachTraceRuntimeCommands(runtime) {
2271
+ printTraceRuntimeHelp();
2272
+ process.stdin.setEncoding("utf8");
2273
+ process.stdin.resume();
2274
+ process.stdin.on("data", (chunk) => {
2275
+ for (const line of chunk.split(/\r?\n/)) {
2276
+ applyTraceRuntimeCommand(line, runtime);
2277
+ }
2278
+ });
2279
+ }
2280
+ function startRequestTraceLogStream(appName, runtime) {
2281
+ const childProcess = spawn("cf", ["logs", appName], {
2282
+ stdio: ["ignore", "pipe", "pipe"],
2283
+ shell: false,
2284
+ windowsHide: true,
2285
+ });
2286
+ childProcess.stdout.on("data", (chunk) => {
2287
+ const lines = chunk.toString("utf8").split(/\r?\n/);
2288
+ for (const line of lines)
2289
+ printRequestTraceLine(appName, line, runtime);
2290
+ });
2291
+ childProcess.stderr.on("data", (chunk) => {
2292
+ const text = chunk.toString("utf8");
2293
+ if (/SMDG_REQUEST_(TRACE|SPY)/.test(text)) {
2294
+ for (const line of text.split(/\r?\n/))
2295
+ printRequestTraceLine(appName, line, runtime);
2296
+ }
2297
+ });
2298
+ return childProcess;
2299
+ }
2300
+ async function runRequestTraceCommand(options) {
2301
+ await maybeSwitchCloudFoundryTargetForDebug({
2302
+ app: options.app,
2303
+ refresh: options.refresh,
2304
+ instance: options.instance,
2305
+ process: options.process,
2306
+ localPort: options.localPort,
2307
+ remotePort: options.remotePort,
2308
+ skipOrgSelect: options.skipOrgSelect,
2309
+ });
2310
+ await ensureCloudFoundrySessionFromCache();
2311
+ const appNames = await resolveRequestTraceApps(options);
2312
+ if (!appNames.length) {
2313
+ throw new Error("No app selected for request trace");
2314
+ }
2315
+ const engine = await searchableSelectChoice({
2316
+ message: "Select request trace engine",
2317
+ choices: [
2318
+ {
2319
+ title: "HTTP watch from existing CF/CDS logs (recommended, stable)",
2320
+ value: "http-watch",
2321
+ description: "Shows method/path/status/user/tenant/size. No restart and no source-code change.",
2322
+ },
2323
+ {
2324
+ title: "Deep Node Inspector trace (experimental body capture)",
2325
+ value: "inspector-trace",
2326
+ description: "Attempts runtime injection. May not work for every CAP runtime. Dev/test only.",
2327
+ },
2328
+ {
2329
+ title: "Doctor: verify traffic, process, and limits",
2330
+ value: "doctor",
2331
+ },
2332
+ ],
2333
+ allowCustomValue: false,
2334
+ });
2335
+ if (engine === "http-watch") {
2336
+ await runHttpWatchForApps({ appNames, recent: false, out: undefined });
2337
+ return;
2338
+ }
2339
+ if (engine === "doctor") {
2340
+ await runRequestTraceDoctorCommand({ ...options, app: appNames.join(",") });
2341
+ return;
2342
+ }
2343
+ const traceMode = await selectRequestTraceMode();
2344
+ const authMode = await selectRequestTraceAuthMode();
2345
+ const displayOptions = await selectRequestTraceDisplayOptions(options);
2346
+ const runtime = {
2347
+ display: displayOptions,
2348
+ filters: { paused: false },
2349
+ events: [],
2350
+ };
2351
+ const instanceIndex = await selectDebugInstance({ instance: options.instance });
2352
+ const baseLocalPort = await selectDebugPort({
2353
+ value: options.localPort,
2354
+ message: "Select first local inspector port for request trace",
2355
+ defaultPort: 9329,
2356
+ });
2357
+ const remotePort = parsePositivePort(options.remotePort, 9229);
2358
+ const maxBodyBytes = parsePositivePort(options.maxBodyBytes, 20000);
2359
+ console.log("");
2360
+ console.log(chalk.yellow("Request trace attaches to the running Node.js app through Node Inspector."));
2361
+ console.log(chalk.gray("It does not modify your repository source code. It is temporary and disappears after app restart."));
2362
+ const prepareMode = await selectNodeInspectorPrepareMode({ appName: appNames.join(", "), remotePort });
2363
+ const tunnelProcesses = [];
2364
+ const logProcesses = [];
2365
+ const stopAll = () => {
2366
+ for (const child of [...tunnelProcesses, ...logProcesses]) {
2367
+ if (!child.killed)
2368
+ child.kill();
2369
+ }
2370
+ };
2371
+ process.once("SIGINT", () => {
2372
+ console.log(chalk.gray("\nStopping request trace..."));
2373
+ stopAll();
2374
+ process.exit(0);
2375
+ });
2376
+ for (const [index, appName] of appNames.entries()) {
2377
+ const localPort = baseLocalPort + index;
2378
+ await ensureSshEnabledForDebug(appName);
2379
+ if (prepareMode === "set-env-restart") {
2380
+ await setNodeInspectorEnvironmentAndRestart({ appName, remotePort });
2381
+ }
2382
+ console.log(chalk.gray(`Opening inspector tunnel for ${appName}: localhost:${localPort} -> 127.0.0.1:${remotePort}`));
2383
+ const tunnelProcess = spawn("cf", buildCloudFoundryDebugSshArgs({
2384
+ appName,
2385
+ instanceIndex,
2386
+ processName: options.process,
2387
+ localPort,
2388
+ remotePort,
2389
+ prepareMode,
2390
+ }), {
2391
+ stdio: ["ignore", "pipe", "pipe"],
2392
+ shell: false,
2393
+ windowsHide: true,
2394
+ });
2395
+ tunnelProcesses.push(tunnelProcess);
2396
+ tunnelProcess.stdout.on("data", (chunk) => process.stdout.write(chalk.gray(`[${appName}:ssh] ${chunk.toString("utf8")}`)));
2397
+ tunnelProcess.stderr.on("data", (chunk) => process.stderr.write(chalk.yellow(`[${appName}:ssh] ${chunk.toString("utf8")}`)));
2398
+ const webSocketUrl = await waitForNodeInspectorWebSocketUrl(localPort, 20000);
2399
+ if (!webSocketUrl) {
2400
+ console.log(chalk.red(`Cannot reach Node Inspector for ${appName} on localhost:${localPort}.`));
2401
+ console.log(chalk.yellow("Try again and choose: Set NODE_OPTIONS and restart app."));
2402
+ continue;
2403
+ }
2404
+ const expression = buildRequestTraceInjectionExpression({
2405
+ appName,
2406
+ mode: traceMode,
2407
+ authMode,
2408
+ maxBodyBytes,
2409
+ parseBodyJson: displayOptions.parseBodyJson,
2410
+ });
2411
+ await sendInspectorEvaluateCommand({ webSocketUrl, expression });
2412
+ console.log(chalk.green(`Request trace injected into ${appName}.`));
2413
+ const logProcess = startRequestTraceLogStream(appName, runtime);
2414
+ logProcesses.push(logProcess);
2415
+ }
2416
+ console.log("");
2417
+ console.log(chalk.green(`Request trace is watching ${appNames.length} app(s).`));
2418
+ console.log(chalk.gray("Send requests to your services. Type /help for runtime search commands. Press Ctrl+C to stop tunnels and log streams."));
2419
+ attachTraceRuntimeCommands(runtime);
2420
+ await new Promise((resolve) => {
2421
+ const watchedProcesses = [...tunnelProcesses, ...logProcesses];
2422
+ let closedCount = 0;
2423
+ for (const child of watchedProcesses) {
2424
+ child.on("close", () => {
2425
+ closedCount += 1;
2426
+ if (closedCount >= watchedProcesses.length)
2427
+ resolve();
2428
+ });
2429
+ }
2430
+ });
2431
+ }
1148
2432
  async function runDebugCommand(options) {
1149
2433
  await maybeSwitchCloudFoundryTargetForDebug(options);
1150
2434
  await ensureCloudFoundrySessionFromCache();
@@ -1412,6 +2696,43 @@ export function registerCloudFoundryCommands(program) {
1412
2696
  .option("--open", "Open current folder in VS Code after creating launch.json")
1413
2697
  .option("--skip-org-select", "Use current CF org/space without asking")
1414
2698
  .action(runDebugCommand);
2699
+ cfCommand
2700
+ .command("http-watch")
2701
+ .alias("watch-http")
2702
+ .description("Watch incoming HTTP requests using existing CF/CDS/RTR logs. Stable and does not modify apps.")
2703
+ .option("--app <appName>", "BTP app name. Use comma-separated names to watch multiple apps")
2704
+ .option("--refresh", "Refresh app list before selecting")
2705
+ .option("--recent", "Parse recent logs and exit")
2706
+ .option("--out <fileName>", "Write parsed HTTP events to a file")
2707
+ .option("--skip-org-select", "Use current CF org/space without asking")
2708
+ .action(runHttpWatchCommand);
2709
+ cfCommand
2710
+ .command("request-trace-doctor")
2711
+ .description("Diagnose why deep request-trace may not capture body/header in a BTP Node.js app")
2712
+ .option("--app <appName>", "BTP app name. Use comma-separated names")
2713
+ .option("--refresh", "Refresh app list before selecting")
2714
+ .option("--instance <index>", "App instance index", "0")
2715
+ .option("--process <processName>", "CF process name for multi-process apps")
2716
+ .option("--local-port <port>", "First local inspector port", "9329")
2717
+ .option("--remote-port <port>", "Remote inspector port in app container", "9229")
2718
+ .option("--max-body-bytes <bytes>", "Maximum request/response body bytes to print", "20000")
2719
+ .option("--skip-org-select", "Use current CF org/space without asking")
2720
+ .action(runRequestTraceDoctorCommand);
2721
+ cfCommand
2722
+ .command("request-trace")
2723
+ .alias("network-trace")
2724
+ .alias("traffic")
2725
+ .description("Watch incoming HTTP requests from BTP Node.js apps without editing backend source code")
2726
+ .option("--app <appName>", "BTP app name. Use comma-separated names to trace multiple apps")
2727
+ .option("--refresh", "Refresh app list before selecting")
2728
+ .option("--instance <index>", "App instance index", "0")
2729
+ .option("--process <processName>", "CF process name for multi-process apps")
2730
+ .option("--local-port <port>", "First local inspector port", "9329")
2731
+ .option("--remote-port <port>", "Remote inspector port in app container", "9229")
2732
+ .option("--max-body-bytes <bytes>", "Maximum request/response body bytes to print", "20000")
2733
+ .option("--out <fileName>", "Export captured trace events to a JSONL file")
2734
+ .option("--skip-org-select", "Use current CF org/space without asking")
2735
+ .action(runRequestTraceCommand);
1415
2736
  cfCommand
1416
2737
  .command("apps-cache-refresh")
1417
2738
  .description("Refresh cached cf apps for current target. Internal command used by smdg cf apps.")