import { readFileSync } from "node:fs"; import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot } from "./config"; import { jsonByteLength, previewJson } from "./preview"; function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } function dockerCoreFetchCommand(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): string[] { const maxResponseBytes = Math.max(1024, Math.floor(init?.maxResponseBytes ?? 5_000_000)); const method = init?.method ?? "GET"; const body = init?.body === undefined ? "" : JSON.stringify(init.body); const url = `http://127.0.0.1:8080${path}`; const script = [ "set -euo pipefail", "if command -v backend-core >/dev/null 2>&1; then", ` exec backend-core --fetch-json ${shellQuote(url)} --method ${shellQuote(method)} --max-response-bytes ${shellQuote(String(maxResponseBytes))}${body.length > 0 ? ` --body-json ${shellQuote(body)}` : ""}`, "fi", `url=${shellQuote(url)}`, `method=${shellQuote(method)}`, `max_bytes=${shellQuote(String(maxResponseBytes))}`, `body=${shellQuote(body)}`, "export url method body max_bytes", "bun -e 'const url=process.env.url; const method=process.env.method; const body=process.env.body; const maxBytes=Number(process.env.max_bytes||\"5000000\"); fetch(url,{method,body:body?body:undefined,headers:body?{\"content-type\":\"application/json\"}:undefined}).then(async r=>{const text=await r.text(); const out={ok:r.ok,status:r.status,body:text?JSON.parse(text):null}; const json=JSON.stringify(out); if (Buffer.byteLength(json) > maxBytes) { console.error(\"response too large\"); process.exit(1); } console.log(json); process.exit(r.ok?0:1);}).catch(e=>{console.error(e); process.exit(1)})'", ].join("\n"); return ["docker", "exec", "unidesk-backend-core", "sh", "-lc", script]; } export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): unknown { if (!path.startsWith("/")) throw new Error("core internal path must start with /"); const command = dockerCoreFetchCommand(path, init); const result = runCommand(command, repoRoot); if (result.exitCode !== 0) { return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) }; } try { return JSON.parse(result.stdout.trim()) as unknown; } catch { return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) }; } } function requireId(value: string | undefined, command: string): string { if (value === undefined || value.length === 0) throw new Error(`${command} requires microservice id`); return value; } function requireProxyPath(value: string | undefined): string { if (value === undefined || value.length === 0) throw new Error("microservice proxy requires upstream path, for example /api/summary"); if (!value.startsWith("/")) throw new Error("microservice proxy upstream path must start with /"); return value; } function encodeId(value: string): string { return encodeURIComponent(value); } function numberOption(args: string[], name: string, defaultValue: number): number { const index = args.indexOf(name); if (index === -1) return defaultValue; const raw = args[index + 1]; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return value; } function cappedNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number { const value = numberOption(args, name, defaultValue); return Math.min(value, maxValue); } function stringOption(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; const raw = args[index + 1]; if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`); return raw; } function hasFlag(args: string[], name: string): boolean { return args.includes(name); } function parseJsonOption(raw: string, name: string): unknown { try { return JSON.parse(raw) as unknown; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`${name} must be valid JSON: ${message}`); } } function requestBodyOption(args: string[]): unknown | undefined { const bodyJson = stringOption(args, "--body-json"); const bodyFile = stringOption(args, "--body-file"); const bodyStdin = hasFlag(args, "--body-stdin"); const sources = [bodyJson !== undefined, bodyFile !== undefined, bodyStdin].filter(Boolean).length; if (sources > 1) throw new Error("microservice proxy accepts only one request body source: --body-json, --body-file, or --body-stdin"); if (bodyJson !== undefined) return parseJsonOption(bodyJson, "--body-json"); if (bodyFile !== undefined) { const text = bodyFile === "-" ? readFileSync(0, "utf8") : readFileSync(bodyFile, "utf8"); return parseJsonOption(text, "--body-file"); } if (bodyStdin) return parseJsonOption(readFileSync(0, "utf8"), "--body-stdin"); return undefined; } function methodOption(args: string[], hasBody = false): string { const method = (stringOption(args, "--method") ?? (hasBody ? "POST" : "GET")).toUpperCase(); if (!["GET", "HEAD", "POST", "DELETE", "PUT", "PATCH"].includes(method)) throw new Error(`unsupported --method ${method}`); if (hasBody && (method === "GET" || method === "HEAD")) throw new Error(`microservice proxy cannot send a request body with ${method}`); return method; } export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown { const full = args.includes("--full"); const raw = args.includes("--raw"); const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000); if (typeof response !== "object" || response === null || Array.isArray(response)) return response; const record = response as Record; if (!("body" in record)) return response; if (record.responseTruncated === true) { return { ...record, bodyOmitted: true, bodyMaxBytes: maxBodyBytes, rawHint: "The upstream response exceeded the CLI collection cap before JSON parsing; re-run with --raw --full and a specific --max-body-bytes only when the full body is required.", }; } const bodyBytes = jsonByteLength(record.body); if (bodyBytes <= maxBodyBytes) { if (!raw || full) return response; return { ...record, outputPolicy: { rawRequested: true, bounded: true, maxBodyBytes, bodyBytes, fullCommand: "Re-run with --raw --full to allow the complete body.", }, }; } const rest = { ...record }; delete rest.body; return { ...rest, bodyOmitted: true, bodyBytes, bodyMaxBytes: maxBodyBytes, bodyPreview: previewJson(record.body, { maxDepth: 3, maxArrayItems: 3, maxObjectKeys: 16, maxStringLength: 320 }), rawHint: raw && !full ? "The --raw response exceeded the default hard limit; re-run with --raw --full for the complete body, or add/tighten __unideskArrayLimit=: in the proxied path." : "Re-run with --raw --full for the complete body, or add/tighten __unideskArrayLimit=: in the proxied path.", }; } export async function runMicroserviceCommand(_config: UniDeskConfig, args: string[]): Promise { const [action = "list", idArg, pathArg] = args; if (action === "list") return coreInternalFetch("/api/microservices"); if (action === "status") { const id = requireId(idArg, "microservice status"); return coreInternalFetch(`/api/microservices/${encodeId(id)}/status`); } if (action === "health") { const id = requireId(idArg, "microservice health"); return coreInternalFetch(`/api/microservices/${encodeId(id)}/health`); } if (action === "diagnostics") { const id = requireId(idArg, "microservice diagnostics"); return coreInternalFetch(`/api/microservices/${encodeId(id)}/diagnostics`); } if (action === "tunnel-self-test") { const id = requireId(idArg, "microservice tunnel-self-test"); return coreInternalFetch(`/api/microservices/${encodeId(id)}/tunnel-self-test`); } if (action === "proxy") { const id = requireId(idArg, "microservice proxy"); const path = requireProxyPath(pathArg); const body = requestBodyOption(args); const full = hasFlag(args, "--full"); const raw = hasFlag(args, "--raw"); const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000); const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000); return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args, body !== undefined), body, maxResponseBytes }), args); } throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy"); }