import { spawn } from "node:child_process"; import { isAbsolute } from "node:path"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_PROBE_TIMEOUT_MS = 5_000; const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024; const ERROR_TEXT_LIMIT = 2_000; const RELEASE_URL = "https://github.com/maxedapps/mdr/releases/latest"; export type MdrSource = | { kind: "text"; value: string } | { kind: "path" | "url"; value: string }; export interface RunMdrRequest { cwd: string; source: MdrSource; outputPath?: string; open?: boolean; signal?: AbortSignal; } export interface RunMdrResult { outputPath: string; openRequested: boolean; } export interface MdrClient { checkMdr(cwd: string): Promise; runMdr(request: RunMdrRequest): Promise; } export interface MdrClientOptions { executable?: string; argvPrefix?: readonly string[]; env?: NodeJS.ProcessEnv; timeoutMs?: number; probeTimeoutMs?: number; maxOutputBytes?: number; } export class MdrError extends Error { readonly outputPath?: string; constructor(message: string, options?: { outputPath?: string; cause?: unknown }) { super(message, { cause: options?.cause }); this.name = "MdrError"; this.outputPath = options?.outputPath; } } export class MdrAvailabilityError extends MdrError { readonly reason: "missing" | "incompatible"; constructor(reason: "missing" | "incompatible", executable: string, cause?: unknown) { super(availabilityMessage(reason, executable), { cause }); this.name = "MdrAvailabilityError"; this.reason = reason; } } interface CapturedProcess { code: number | null; stdout: string; stderr: string; } class ProcessControlError extends Error { constructor( readonly kind: "launch" | "abort" | "timeout" | "output-limit", message: string, options?: { cause?: unknown }, ) { super(message, options); } } export function createMdrClient(options: MdrClientOptions = {}): MdrClient { const env = options.env ?? process.env; const executable = options.executable ?? env.MDR_BIN ?? "mdr"; const argvPrefix = [...(options.argvPrefix ?? [])]; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; let successfulProbe: Promise | undefined; async function probe(cwd: string): Promise { let result: CapturedProcess; try { result = await captureProcess({ executable, args: [...argvPrefix, "--help"], cwd, env, timeoutMs: probeTimeoutMs, maxOutputBytes, }); } catch (error) { if (error instanceof ProcessControlError && error.kind === "launch") { throw new MdrAvailabilityError("missing", executable, error); } throw new MdrAvailabilityError("incompatible", executable, error); } const help = `${result.stdout}\n${result.stderr}`; if (result.code !== 0 || !help.includes("--no-open") || !help.includes("--out")) { throw new MdrAvailabilityError("incompatible", executable); } } async function checkMdr(cwd: string): Promise { if (!successfulProbe) { const pending = probe(cwd); successfulProbe = pending; try { await pending; } catch (error) { if (successfulProbe === pending) successfulProbe = undefined; throw error; } return; } await successfulProbe; } async function runMdr(request: RunMdrRequest): Promise { await checkMdr(request.cwd); if (request.outputPath && !isAbsolute(request.outputPath)) { throw new MdrError("MDR outputPath must be absolute."); } const openRequested = request.open ?? true; const args = [...argvPrefix]; if (!openRequested) args.push("--no-open"); if (request.outputPath) args.push("--out", request.outputPath); if (request.source.kind !== "text") args.push("--", request.source.value); let result: CapturedProcess; try { result = await captureProcess({ executable, args, cwd: request.cwd, env, stdin: request.source.kind === "text" ? request.source.value : undefined, signal: request.signal, timeoutMs, maxOutputBytes, }); } catch (error) { if (error instanceof ProcessControlError) { if (error.kind === "abort") { const aborted = new MdrError("MDR rendering was cancelled.", { cause: error }); aborted.name = "AbortError"; throw aborted; } if (error.kind === "timeout") { throw new MdrError(`MDR rendering timed out after ${timeoutMs}ms.`, { cause: error }); } if (error.kind === "output-limit") { throw new MdrError(`MDR output exceeded ${maxOutputBytes} bytes.`, { cause: error }); } } throw new MdrAvailabilityError("missing", executable, error); } const printedPath = parsePrintedPath(result.stdout); if (result.code !== 0) { const detail = concise(result.stderr) || `exit code ${result.code ?? "unknown"}`; const pathSuffix = printedPath ? ` Generated HTML: ${printedPath}` : ""; throw new MdrError(`MDR failed: ${detail}.${pathSuffix}`.replace("..", "."), { outputPath: printedPath, }); } if (!printedPath) { throw new MdrError("MDR succeeded without printing one absolute HTML path."); } return { outputPath: printedPath, openRequested }; } return { checkMdr, runMdr }; } function parsePrintedPath(stdout: string): string | undefined { const lines = stdout .split(/\r?\n/u) .map((line) => line.trim()) .filter(Boolean); return lines.length === 1 && isAbsolute(lines[0]!) ? lines[0] : undefined; } function concise(value: string): string { const normalized = value.replace(/\s+/gu, " ").trim(); if (Buffer.byteLength(normalized) <= ERROR_TEXT_LIMIT) return normalized; return `${Buffer.from(normalized).subarray(0, ERROR_TEXT_LIMIT).toString("utf8").trimEnd()}…`; } function availabilityMessage(reason: "missing" | "incompatible", executable: string): string { const installer = process.platform === "win32" ? 'powershell -ExecutionPolicy Bypass -c "irm https://github.com/maxedapps/mdr/releases/latest/download/mdr-installer.ps1 | iex"' : "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/maxedapps/mdr/releases/latest/download/mdr-installer.sh | sh"; const problem = reason === "missing" ? `MDR is required but could not be started (${executable}).` : `The installed MDR is incompatible; update it to a release that supports --no-open and --out (${executable}).`; return `${problem}\nInstall/update MDR: ${installer}\nReleases: ${RELEASE_URL}\nEnsure mdr is on PATH and restart Pi, or set MDR_BIN to the executable path.`; } function captureProcess(input: { executable: string; args: string[]; cwd: string; env: NodeJS.ProcessEnv; stdin?: string; signal?: AbortSignal; timeoutMs: number; maxOutputBytes: number; }): Promise { if (input.signal?.aborted) { return Promise.reject(new ProcessControlError("abort", "Process aborted before launch.")); } return new Promise((resolve, reject) => { let settled = false; let controlError: ProcessControlError | undefined; let stdoutBytes = 0; let stderrBytes = 0; const stdout: Buffer[] = []; const stderr: Buffer[] = []; const child = spawn(input.executable, input.args, { cwd: input.cwd, env: input.env, shell: false, stdio: ["pipe", "pipe", "pipe"], }); const forceKill = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }; let forceKillTimer: NodeJS.Timeout | undefined; const stop = (error: ProcessControlError) => { if (controlError) return; controlError = error; child.kill("SIGTERM"); forceKillTimer = setTimeout(forceKill, 250); forceKillTimer.unref(); }; const onAbort = () => stop(new ProcessControlError("abort", "Process aborted.")); input.signal?.addEventListener("abort", onAbort, { once: true }); const timeout = setTimeout( () => stop(new ProcessControlError("timeout", "Process timed out.")), input.timeoutMs, ); timeout.unref(); const append = (target: Buffer[], chunk: Buffer, stream: "stdout" | "stderr") => { if (stream === "stdout") stdoutBytes += chunk.length; else stderrBytes += chunk.length; if (stdoutBytes > input.maxOutputBytes || stderrBytes > input.maxOutputBytes) { stop(new ProcessControlError("output-limit", "Process output limit exceeded.")); return; } target.push(chunk); }; child.stdout.on("data", (chunk: Buffer) => append(stdout, chunk, "stdout")); child.stderr.on("data", (chunk: Buffer) => append(stderr, chunk, "stderr")); child.stdin.on("error", () => undefined); child.on("error", (error: NodeJS.ErrnoException) => { if (settled) return; settled = true; cleanup(); reject(new ProcessControlError("launch", error.message, { cause: error })); }); child.on("close", (code) => { if (settled) return; settled = true; cleanup(); if (controlError) { reject(controlError); return; } resolve({ code, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), }); }); child.stdin.end(input.stdin); function cleanup() { clearTimeout(timeout); if (forceKillTimer) clearTimeout(forceKillTimer); input.signal?.removeEventListener("abort", onAbort); } }); }