simplemdg-dev-cli 1.5.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.
- package/README.md +118 -5
- package/USER_GUIDE.md +134 -5
- package/dist/commands/cf.command.js +1430 -14
- package/dist/commands/cf.command.js.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
|
@@ -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";
|
|
@@ -74,6 +76,71 @@ async function ensureCloudFoundrySessionFromCache() {
|
|
|
74
76
|
});
|
|
75
77
|
return readCloudFoundryTarget();
|
|
76
78
|
}
|
|
79
|
+
function sortCloudFoundryProfilesForEndpoint(options) {
|
|
80
|
+
const profilesWithPassword = options.profiles.filter((profile) => profile.password?.trim());
|
|
81
|
+
return [
|
|
82
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint === options.apiEndpoint && profile.org === options.preferredOrg),
|
|
83
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint === options.apiEndpoint && profile.org !== options.preferredOrg),
|
|
84
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint !== options.apiEndpoint && profile.org === options.preferredOrg),
|
|
85
|
+
...profilesWithPassword.filter((profile) => profile.apiEndpoint !== options.apiEndpoint && profile.org !== options.preferredOrg),
|
|
86
|
+
].filter((profile, index, array) => {
|
|
87
|
+
return array.findIndex((item) => {
|
|
88
|
+
return item.apiEndpoint === profile.apiEndpoint
|
|
89
|
+
&& item.username === profile.username
|
|
90
|
+
&& item.password === profile.password
|
|
91
|
+
&& item.org === profile.org
|
|
92
|
+
&& item.space === profile.space;
|
|
93
|
+
}) === index;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function ensureCloudFoundryAuthenticatedForApiEndpoint(options) {
|
|
97
|
+
const apiExitCode = await setCloudFoundryApiEndpoint(options.apiEndpoint);
|
|
98
|
+
if (apiExitCode !== 0) {
|
|
99
|
+
process.exitCode = apiExitCode;
|
|
100
|
+
throw new Error(`Cannot set CF API endpoint: ${options.apiEndpoint}`);
|
|
101
|
+
}
|
|
102
|
+
const orgsCheck = await runCommand("cf", ["orgs"]);
|
|
103
|
+
if (orgsCheck.exitCode === 0) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const cache = await readCache();
|
|
107
|
+
const profiles = sortCloudFoundryProfilesForEndpoint({
|
|
108
|
+
profiles: cache.cloudFoundry.loginProfiles,
|
|
109
|
+
apiEndpoint: options.apiEndpoint,
|
|
110
|
+
preferredOrg: options.preferredOrg,
|
|
111
|
+
});
|
|
112
|
+
if (!profiles.length) {
|
|
113
|
+
console.log(chalk.yellow(`Not logged in to ${inferCloudFoundryRegionFromApiEndpoint(options.apiEndpoint)} and no cached password was found for automatic login.`));
|
|
114
|
+
console.log(chalk.gray("Run smdg cf login once, choose to save password, then retry this command."));
|
|
115
|
+
throw new Error("Cloud Foundry automatic login is required");
|
|
116
|
+
}
|
|
117
|
+
let lastError = orgsCheck.stderr || orgsCheck.stdout || "cf orgs failed";
|
|
118
|
+
for (const profile of profiles) {
|
|
119
|
+
console.log(chalk.gray(`Auto auth CF ${inferCloudFoundryRegionFromApiEndpoint(options.apiEndpoint)} as ${profile.username}...`));
|
|
120
|
+
const authExitCode = await authenticateCloudFoundry({
|
|
121
|
+
username: profile.username,
|
|
122
|
+
password: profile.password,
|
|
123
|
+
});
|
|
124
|
+
if (authExitCode !== 0) {
|
|
125
|
+
lastError = `cf auth failed for ${profile.username}`;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const nextOrgsCheck = await runCommand("cf", ["orgs"]);
|
|
129
|
+
if (nextOrgsCheck.exitCode === 0) {
|
|
130
|
+
const updatedProfile = {
|
|
131
|
+
...profile,
|
|
132
|
+
apiEndpoint: options.apiEndpoint,
|
|
133
|
+
org: options.preferredOrg || profile.org,
|
|
134
|
+
space: options.preferredSpace || profile.space,
|
|
135
|
+
updatedAt: new Date().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
await rememberCloudFoundryLoginProfile(updatedProfile);
|
|
138
|
+
return updatedProfile;
|
|
139
|
+
}
|
|
140
|
+
lastError = nextOrgsCheck.stderr || nextOrgsCheck.stdout || lastError;
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`CF automatic login failed for ${options.apiEndpoint}. ${lastError}`);
|
|
143
|
+
}
|
|
77
144
|
function buildCloudFoundryLogsArgs(options) {
|
|
78
145
|
const args = ["logs", options.appName];
|
|
79
146
|
if (options.recent) {
|
|
@@ -100,13 +167,20 @@ function buildNodeInspectorRemoteCommand(remotePort) {
|
|
|
100
167
|
`exit 2`,
|
|
101
168
|
].join("; ");
|
|
102
169
|
}
|
|
103
|
-
|
|
104
|
-
`PID
|
|
105
|
-
`if
|
|
106
|
-
`if
|
|
107
|
-
`
|
|
170
|
+
const detectNodePidScript = [
|
|
171
|
+
`PID=""`,
|
|
172
|
+
`if command -v pgrep >/dev/null 2>&1; then PID=$(pgrep -f "(^|/| )node( |$)" 2>/dev/null | head -n 1 || true); fi`,
|
|
173
|
+
`if [ -z "$PID" ]; then PID=$(ps -eo pid=,args= 2>/dev/null | awk '/[n]ode/ && $0 !~ /awk/ && $0 !~ /pgrep/ {print $1; exit}'); fi`,
|
|
174
|
+
`if [ -z "$PID" ]; then echo "No Node.js PID found in app container. Use prepare mode: Set NODE_OPTIONS and restart app." >&2; ps -eo pid,args 2>/dev/null | head -n 40 >&2; exit 1; fi`,
|
|
175
|
+
`echo "Detected Node.js PID: $PID"`,
|
|
176
|
+
`if command -v ss >/dev/null 2>&1 && ss -H -ntl "sport = :9229" | grep -q .; then echo "Node inspector already listening on 127.0.0.1:9229"; tail -f /dev/null; fi`,
|
|
177
|
+
`if command -v netstat >/dev/null 2>&1 && netstat -ntl 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)9229$'; then echo "Node inspector already listening on 127.0.0.1:9229"; tail -f /dev/null; fi`,
|
|
178
|
+
`kill -USR1 "$PID" || { echo "Cannot send SIGUSR1 to Node.js PID $PID. Use prepare mode: Set NODE_OPTIONS and restart app." >&2; exit 1; }`,
|
|
179
|
+
`echo "Requested Node inspector for PID $PID on 127.0.0.1:9229"`,
|
|
180
|
+
`COUNT=0; while [ "$COUNT" -lt 20 ]; do if command -v ss >/dev/null 2>&1 && ss -H -ntl "sport = :9229" | grep -q .; then echo "Node inspector is listening on 127.0.0.1:9229"; break; fi; if command -v netstat >/dev/null 2>&1 && netstat -ntl 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)9229$'; then echo "Node inspector is listening on 127.0.0.1:9229"; break; fi; COUNT=$((COUNT + 1)); sleep 1; done`,
|
|
108
181
|
`tail -f /dev/null`,
|
|
109
|
-
]
|
|
182
|
+
];
|
|
183
|
+
return detectNodePidScript.join("; ");
|
|
110
184
|
}
|
|
111
185
|
function buildKeepAliveRemoteCommand() {
|
|
112
186
|
return [
|
|
@@ -512,6 +586,7 @@ function refreshAppsCacheInDetachedProcess() {
|
|
|
512
586
|
childProcess.unref();
|
|
513
587
|
}
|
|
514
588
|
async function getAppsWithCache(options) {
|
|
589
|
+
await ensureCloudFoundrySessionFromCache();
|
|
515
590
|
if (options.refresh) {
|
|
516
591
|
return refreshAppsCacheForCurrentTarget();
|
|
517
592
|
}
|
|
@@ -562,15 +637,24 @@ function printTarget(target) {
|
|
|
562
637
|
const DEFAULT_CLOUD_FOUNDRY_API_ENDPOINTS = [
|
|
563
638
|
"https://api.cf.br10.hana.ondemand.com",
|
|
564
639
|
"https://api.cf.eu10.hana.ondemand.com",
|
|
640
|
+
"https://api.cf.eu10-004.hana.ondemand.com",
|
|
641
|
+
"https://api.cf.eu10-005.hana.ondemand.com",
|
|
642
|
+
"https://api.cf.eu20.hana.ondemand.com",
|
|
643
|
+
"https://api.cf.eu20-001.hana.ondemand.com",
|
|
644
|
+
"https://api.cf.eu20-002.hana.ondemand.com",
|
|
565
645
|
"https://api.cf.us10.hana.ondemand.com",
|
|
646
|
+
"https://api.cf.us10-001.hana.ondemand.com",
|
|
647
|
+
"https://api.cf.us11.hana.ondemand.com",
|
|
648
|
+
"https://api.cf.us20.hana.ondemand.com",
|
|
649
|
+
"https://api.cf.us21.hana.ondemand.com",
|
|
566
650
|
"https://api.cf.ap10.hana.ondemand.com",
|
|
567
651
|
"https://api.cf.ap11.hana.ondemand.com",
|
|
652
|
+
"https://api.cf.ap20.hana.ondemand.com",
|
|
568
653
|
"https://api.cf.ap21.hana.ondemand.com",
|
|
569
654
|
"https://api.cf.jp10.hana.ondemand.com",
|
|
570
655
|
"https://api.cf.ca10.hana.ondemand.com",
|
|
571
656
|
"https://api.cf.ch20.hana.ondemand.com",
|
|
572
|
-
"https://api.cf.
|
|
573
|
-
"https://api.cf.us20.hana.ondemand.com",
|
|
657
|
+
"https://api.cf.sa10.hana.ondemand.com",
|
|
574
658
|
];
|
|
575
659
|
function uniqueValues(values) {
|
|
576
660
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
@@ -771,6 +855,7 @@ function getCloudFoundryApiEndpointsForOrgSearch(options, target, cache) {
|
|
|
771
855
|
options.api ?? "",
|
|
772
856
|
target.apiEndpoint ?? "",
|
|
773
857
|
...cache.cloudFoundry.loginProfiles.map((item) => item.apiEndpoint),
|
|
858
|
+
...cache.cloudFoundry.orgsAcrossRegions.map((item) => item.apiEndpoint),
|
|
774
859
|
...DEFAULT_CLOUD_FOUNDRY_API_ENDPOINTS,
|
|
775
860
|
]);
|
|
776
861
|
}
|
|
@@ -890,14 +975,16 @@ async function runOrgCommand(options) {
|
|
|
890
975
|
if (!selectedEntry.apiEndpoint) {
|
|
891
976
|
throw new Error("Cannot determine CF API endpoint for selected org.");
|
|
892
977
|
}
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
978
|
+
const authenticatedProfile = await ensureCloudFoundryAuthenticatedForApiEndpoint({
|
|
979
|
+
apiEndpoint: selectedEntry.apiEndpoint,
|
|
980
|
+
preferredOrg: selectedEntry.org,
|
|
981
|
+
preferredSpace: options.space,
|
|
982
|
+
reason: "switch-org",
|
|
983
|
+
});
|
|
898
984
|
const orgExitCode = await targetCloudFoundryOrg(selectedEntry.org);
|
|
899
985
|
if (orgExitCode !== 0) {
|
|
900
|
-
console.log(chalk.yellow("Cannot switch to this org
|
|
986
|
+
console.log(chalk.yellow("Cannot switch to this org after automatic authentication."));
|
|
987
|
+
console.log(chalk.gray("Run smdg cf login, save the password, then try again."));
|
|
901
988
|
process.exitCode = orgExitCode;
|
|
902
989
|
return;
|
|
903
990
|
}
|
|
@@ -924,6 +1011,15 @@ async function runOrgCommand(options) {
|
|
|
924
1011
|
return;
|
|
925
1012
|
}
|
|
926
1013
|
}
|
|
1014
|
+
if (authenticatedProfile?.password) {
|
|
1015
|
+
await rememberCloudFoundryLoginProfile({
|
|
1016
|
+
...authenticatedProfile,
|
|
1017
|
+
apiEndpoint: selectedEntry.apiEndpoint,
|
|
1018
|
+
org: selectedEntry.org,
|
|
1019
|
+
space,
|
|
1020
|
+
updatedAt: new Date().toISOString(),
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
927
1023
|
const switchedTarget = await readCloudFoundryTarget();
|
|
928
1024
|
console.log(chalk.green("CF org/space switched."));
|
|
929
1025
|
printTarget(switchedTarget);
|
|
@@ -1051,8 +1147,1291 @@ async function runLogsCommand(options) {
|
|
|
1051
1147
|
childProcess.on("close", () => resolve());
|
|
1052
1148
|
});
|
|
1053
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
|
+
}
|
|
1054
2432
|
async function runDebugCommand(options) {
|
|
1055
2433
|
await maybeSwitchCloudFoundryTargetForDebug(options);
|
|
2434
|
+
await ensureCloudFoundrySessionFromCache();
|
|
1056
2435
|
const appName = await resolveAppSelection({
|
|
1057
2436
|
app: options.app,
|
|
1058
2437
|
refresh: options.refresh,
|
|
@@ -1317,6 +2696,43 @@ export function registerCloudFoundryCommands(program) {
|
|
|
1317
2696
|
.option("--open", "Open current folder in VS Code after creating launch.json")
|
|
1318
2697
|
.option("--skip-org-select", "Use current CF org/space without asking")
|
|
1319
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);
|
|
1320
2736
|
cfCommand
|
|
1321
2737
|
.command("apps-cache-refresh")
|
|
1322
2738
|
.description("Refresh cached cf apps for current target. Internal command used by smdg cf apps.")
|