import http from "node:http"; import { readFile } from "node:fs/promises"; import { getConfig } from "./config.js"; import type { CharlesEntry } from "./types.js"; export const THROTTLE_PRESETS = [ "56 kbps Modem", "256 kbps ISDN/DSL", "512 kbps ISDN/DSL", "2 Mbps ADSL", "8 Mbps ADSL2", "16 Mbps ADSL2+", "32 Mbps VDSL", "32 Mbps Fibre", "100 Mbps Fibre", "3G", "4G", ] as const; /** * GET against Charles control API via the local proxy. * Charles resolves the special host `control.charles` only when traffic * goes through its proxy. * * Connection settings are read live from getConfig() so /charles changes * take effect immediately. */ function charlesGet( path: string, opts: { timeoutMs?: number; query?: Record } = {}, ): Promise<{ status: number; body: string }> { const { timeoutMs = 10_000, query } = opts; const cfg = getConfig(); const auth = "Basic " + Buffer.from(`${cfg.username}:${cfg.password}`).toString("base64"); const qs = query ? "?" + new URLSearchParams(query).toString() : ""; const fullPath = `http://control.charles${path}${qs}`; return new Promise((resolve, reject) => { const req = http.request( { host: cfg.proxyHost, port: cfg.proxyPort, path: fullPath, method: "GET", headers: { Host: "control.charles", Authorization: auth, Connection: "close", }, }, (res) => { const chunks: Buffer[] = []; res.on("data", (c: Buffer) => chunks.push(c)); res.on("end", () => { resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8"), }); }); }, ); req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Charles request timed out after ${timeoutMs}ms`)); }); req.on("error", reject); req.end(); }); } /** Probe Charles connectivity with the current config. */ export async function testConnection(): Promise<{ ok: boolean; message: string; }> { const cfg = getConfig(); try { const res = await charlesGet("/session/export-json", { timeoutMs: 5_000 }); if (res.status >= 200 && res.status < 300) { let count = 0; try { const data = JSON.parse(res.body) as unknown; if (Array.isArray(data)) count = data.length; } catch { // body may be empty } return { ok: true, message: `Connected to Charles at ${cfg.proxyHost}:${cfg.proxyPort} (${count} session entries)`, }; } if (res.status === 401 || res.status === 403) { return { ok: false, message: `Authentication failed (HTTP ${res.status}). Check username/password.`, }; } return { ok: false, message: `Charles responded with HTTP ${res.status}`, }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); return { ok: false, message: `Cannot reach Charles at ${cfg.proxyHost}:${cfg.proxyPort}: ${detail}`, }; } } /** Read a local .chlsj recording file. */ export async function readChlsj(filePath: string): Promise { const raw = await readFile(filePath, "utf8"); const data = JSON.parse(raw) as unknown; if (!Array.isArray(data)) { throw new Error( `Not a valid .chlsj file (expected list, got ${typeof data})`, ); } return data as CharlesEntry[]; } /** Export current Charles session (read-only, no side effects). */ export async function exportSession(): Promise { try { const res = await charlesGet("/session/export-json", { timeoutMs: 15_000 }); if (res.status < 200 || res.status >= 300) return []; const data = JSON.parse(res.body) as unknown; return Array.isArray(data) ? (data as CharlesEntry[]) : []; } catch { return []; } } /** Clear Charles session and restart recording. */ export async function clearAndRestart(): Promise { try { await charlesGet("/session/clear", { timeoutMs: 5_000 }); await charlesGet("/recording/start", { timeoutMs: 5_000 }); return true; } catch { return false; } } /** Silently deactivate throttling (for session shutdown). */ export async function deactivateThrottlingSilent(): Promise { try { await charlesGet("/throttling/deactivate", { timeoutMs: 3_000 }); } catch { // ignore } } /** Activate or deactivate Charles throttling. */ export async function setCharlesThrottling( presetName: string | null, ): Promise<{ success: boolean; message?: string }> { try { if (!presetName) { await charlesGet("/throttling/deactivate", { timeoutMs: 5_000 }); return { success: true, message: "Throttling disabled" }; } if (!(THROTTLE_PRESETS as readonly string[]).includes(presetName)) { return { success: false, message: `Invalid preset: '${presetName}'. Available: ${THROTTLE_PRESETS.join(", ")}`, }; } await charlesGet("/throttling/activate", { timeoutMs: 5_000, query: { preset: presetName }, }); return { success: true, message: `Throttling enabled: ${presetName}` }; } catch (err) { return { success: false, message: `Throttling failed: ${err instanceof Error ? err.message : String(err)}`, }; } }