Files
Flights/scripts/jsx_live_monitor.mjs
Trey t 6005146e75 Airline integration work: AirlineLoadService updates, docs, JSX scripts
- AirlineLoadService: pass airport DB for timezone-aware date strings,
  add browser-shaped headers for United, expand JetBlue/Alaska/Emirates
  signatures to take origin, log/parse fixes for Korean Air.
- FlightsApp: build AirlineLoadService with the airport DB and inject it.
- JSX: continued WebView-based fetcher work plus updated JSX_NOTES.
- Docs: add AIRLINE_INTEGRATION_GUIDE.md, drop the old AIRLINE_API_SPEC.md,
  add api_docs/ (StaffTraveler reverse-engineering captures + findings).
- Scripts: jsx_cdp_probe, jsx_live_monitor, jsx_swift_smoke for JSX
  protocol exploration.
- .gitignore: exclude airlines/ (local-only APK/IPA reverse-engineering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:21:30 -05:00

295 lines
8.1 KiB
JavaScript

import { spawn } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const port = Number(process.env.CDP_PORT || 9230);
const navigationUrl = process.env.JSX_URL || "https://www.jsx.com/";
const userDataDir = process.env.CHROME_USER_DATA_DIR || `/tmp/jsx-live-monitor-${Date.now()}`;
const artifactsDir = process.env.JSX_ARTIFACTS_DIR || "/tmp/jsx-live-monitor";
const logPath = `${artifactsDir}/network-events.ndjson`;
const requestTimeoutMs = Number(process.env.REQUEST_TIMEOUT_MS || 30000);
await mkdir(artifactsDir, { recursive: true });
const logStream = createWriteStream(logPath, { flags: "a" });
function writeLine(line) {
console.log(line);
logStream.write(`${line}\n`);
}
function log(message, details) {
if (details === undefined) {
writeLine(`[monitor] ${message}`);
return;
}
writeLine(`[monitor] ${message} ${JSON.stringify(details)}`);
}
async function fetchJson(url, init = undefined) {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
async function waitForDebugger() {
const deadline = Date.now() + requestTimeoutMs;
while (Date.now() < deadline) {
try {
return await fetchJson(`http://127.0.0.1:${port}/json/version`);
} catch {
await delay(250);
}
}
throw new Error("Timed out waiting for Chrome remote debugger");
}
async function waitForPageTarget() {
const deadline = Date.now() + requestTimeoutMs;
while (Date.now() < deadline) {
try {
const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`);
const pageTarget = targets.find(target =>
target.type === "page" &&
typeof target.url === "string" &&
target.url.startsWith("http") &&
target.webSocketDebuggerUrl
);
if (pageTarget) {
return pageTarget;
}
} catch {
// Ignore transient startup failures while Chrome is coming up.
}
await delay(250);
}
throw new Error("Timed out waiting for initial Chrome page target");
}
function launchChrome() {
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-default-apps",
"--disable-popup-blocking",
"--window-size=1440,1200",
navigationUrl,
];
log("Launching Chrome", { chromePath, port, userDataDir, navigationUrl, logPath });
const child = spawn(chromePath, args, {
detached: false,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", chunk => {
const text = chunk.toString().trim();
if (text) {
writeLine(`[chrome] ${text}`);
}
});
child.stderr.on("data", chunk => {
const text = chunk.toString().trim();
if (text) {
writeLine(`[chrome] ${text}`);
}
});
return child;
}
class CDPClient {
constructor(socketUrl) {
this.socketUrl = socketUrl;
this.ws = new WebSocket(socketUrl);
this.nextId = 1;
this.pending = new Map();
this.eventHandlers = new Map();
this.openPromise = new Promise((resolve, reject) => {
this.ws.addEventListener("open", () => resolve());
this.ws.addEventListener("error", event => reject(event.error || new Error("WebSocket open failed")));
});
this.ws.addEventListener("message", event => {
const message = JSON.parse(event.data.toString());
if (message.id) {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pending.resolve(message.result);
}
return;
}
const handlers = this.eventHandlers.get(message.method) || [];
for (const handler of handlers) {
handler(message.params || {});
}
});
}
async ready() {
await this.openPromise;
}
async send(method, params = {}) {
await this.ready();
const id = this.nextId++;
const payload = JSON.stringify({ id, method, params });
const promise = new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error(`Timed out waiting for ${method}`));
}
}, requestTimeoutMs);
});
this.ws.send(payload);
return promise;
}
on(method, handler) {
const handlers = this.eventHandlers.get(method) || [];
handlers.push(handler);
this.eventHandlers.set(method, handlers);
}
async close() {
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
}
}
let shuttingDown = false;
async function main() {
const chrome = launchChrome();
let cdp;
const shutdown = async signal => {
if (shuttingDown) {
return;
}
shuttingDown = true;
log("Shutting down", { signal });
if (cdp) {
await cdp.close();
}
chrome.kill("SIGTERM");
await delay(500);
await rm(userDataDir, { recursive: true, force: true });
logStream.end();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
try {
await waitForDebugger();
const target = await waitForPageTarget();
cdp = new CDPClient(target.webSocketDebuggerUrl);
await cdp.ready();
log("Attached to page target", { id: target.id, url: target.url });
log("Manual monitor ready", { instructions: "Drive the JSX page in Chrome. All api.jsx.com traffic will stream here." });
const responseMetaByRequestId = new Map();
cdp.on("Page.frameNavigated", params => {
const url = params.frame?.url;
if (url) {
log("Page navigated", { url });
}
});
cdp.on("Network.requestWillBeSent", params => {
const url = params.request?.url || "";
if (!url.includes("api.jsx.com")) {
return;
}
const record = {
stage: "request",
type: params.type || "",
method: params.request?.method || "",
url,
postData: params.request?.postData || "",
hasAuthHeader: Boolean(params.request?.headers?.Authorization),
};
writeLine(JSON.stringify(record));
});
cdp.on("Network.responseReceived", params => {
const url = params.response?.url || "";
if (!url.includes("api.jsx.com")) {
return;
}
const record = {
stage: "response",
type: params.type || "",
status: params.response?.status || 0,
mimeType: params.response?.mimeType || "",
url,
};
responseMetaByRequestId.set(params.requestId, record);
writeLine(JSON.stringify(record));
});
cdp.on("Network.loadingFinished", async params => {
const meta = responseMetaByRequestId.get(params.requestId);
if (!meta || !meta.url.includes("api.jsx.com")) {
return;
}
try {
const body = await cdp.send("Network.getResponseBody", { requestId: params.requestId });
writeLine(JSON.stringify({
stage: "body",
status: meta.status,
url: meta.url,
bodySnippet: (body.body || "").slice(0, 4000),
base64Encoded: Boolean(body.base64Encoded),
}));
} catch (error) {
writeLine(JSON.stringify({
stage: "body-error",
url: meta.url,
error: error.message,
}));
}
});
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await cdp.send("Network.enable", {
maxTotalBufferSize: 100_000_000,
maxResourceBufferSize: 10_000_000,
maxPostDataSize: 1_000_000,
});
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await new Promise(() => {});
} catch (error) {
log("Monitor failed", { error: error.message });
await shutdown("error");
}
}
await main();