// lib/pdf-render-letter.ts // SERVER-ONLY. Renders letter HTML to a PDF buffer. // // WHY THIS EXISTS // The nine letter routes (resignations, appointments, reports) all render // their attachment by POSTing to an external "pdfd" service at PDFD_URL, // defaulting to http://127.0.0.1:3070. That service was never ported off // the Mac — on this PC nothing listens on 3070 and there is no pdfd binary // anywhere on disk. The result was that every letter went out with no PDF // attachment at all, while the signing link in the body pointed at a // hostname that no longer resolves to this machine. Directors got neither // a usable link nor a document. // // This module renders locally using the Chrome/Edge that is already // installed, so attachments work with no extra service to run. // // Order of preference: // 1. An explicit PDFD_URL that is actually reachable (keeps any existing // deployment working). // 2. Local Chrome/Edge headless print-to-pdf. // // Both paths return Buffer | null and NEVER throw, so a caller can degrade // to a body-only email rather than losing the letter entirely. import "server-only"; import { promises as fs } from "fs"; import os from "os"; import path from "path"; import { spawn } from "child_process"; import { existsSync } from "fs"; /** Candidate browsers, most-preferred first. */ const BROWSERS = [ process.env.PDF_BROWSER, process.env.PROGRAMFILES && path.join(process.env.PROGRAMFILES, "Google", "Chrome", "Application", "chrome.exe"), process.env["PROGRAMFILES(X86)"] && path.join(process.env["PROGRAMFILES(X86)"], "Google", "Chrome", "Application", "chrome.exe"), process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe"), process.env["PROGRAMFILES(X86)"] && path.join(process.env["PROGRAMFILES(X86)"], "Microsoft", "Edge", "Application", "msedge.exe"), process.env.PROGRAMFILES && path.join(process.env.PROGRAMFILES, "Microsoft", "Edge", "Application", "msedge.exe"), "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", ].filter((p): p is string => typeof p === "string" && p.length > 0); function findBrowser(): string | null { for (const b of BROWSERS) { try { if (existsSync(b)) return b; } catch { /* keep looking */ } } return null; } function run( bin: string, args: string[], timeoutMs: number ): Promise<{ code: number; stderr: string; spawnError?: string }> { return new Promise((resolve) => { let settled = false; let stderr = ""; const done = (r: { code: number; stderr: string; spawnError?: string }) => { if (!settled) { settled = true; resolve(r); } }; let child; try { child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"], windowsHide: true }); } catch (e) { done({ code: -1, stderr, spawnError: e instanceof Error ? e.message : String(e) }); return; } const timer = setTimeout(() => { try { child.kill(); } catch { /* already gone */ } done({ code: -1, stderr, spawnError: `timed out after ${timeoutMs}ms` }); }, timeoutMs); child.stderr?.on("data", (d) => { stderr += d.toString(); }); child.on("error", (err) => { clearTimeout(timer); done({ code: -1, stderr, spawnError: err.message }); }); child.on("close", (code) => { clearTimeout(timer); done({ code: code ?? -1, stderr }); }); }); } async function renderViaPdfd(html: string, filename: string): Promise { const base = (process.env.PDFD_URL || "http://127.0.0.1:3070").replace(/\/$/, ""); try { const res = await fetch(`${base}/pdf`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ html, filename }), signal: AbortSignal.timeout(20000), }); if (!res.ok) return null; const data = (await res.json()) as { pdf?: string }; if (!data?.pdf) return null; return Buffer.from(data.pdf, "base64"); } catch { return null; // service not running — expected on this PC } } async function renderViaBrowser(html: string): Promise { const browser = findBrowser(); if (!browser) { console.error("[pdf] No Chrome/Edge found. Set PDF_BROWSER to the executable path."); return null; } const dir = await fs.mkdtemp(path.join(os.tmpdir(), "wf-letter-")); const htmlPath = path.join(dir, "letter.html"); const pdfPath = path.join(dir, "letter.pdf"); const profileDir = path.join(dir, "profile"); try { await fs.writeFile(htmlPath, html, "utf8"); const fileUrl = `file:///${htmlPath.replace(/\\/g, "/")}`; const r = await run( browser, [ "--headless=new", "--disable-gpu", "--no-sandbox", "--no-first-run", "--disable-extensions", `--user-data-dir=${profileDir}`, "--print-to-pdf-no-header", "--no-pdf-header-footer", `--print-to-pdf=${pdfPath}`, fileUrl, ], 60000 ); if (r.spawnError) { console.error("[pdf] browser render failed:", r.spawnError); return null; } // Chrome can exit non-zero yet still write the file, so check the file // before trusting the exit code. if (!existsSync(pdfPath)) { console.error("[pdf] browser produced no file. stderr:", r.stderr.slice(0, 400)); return null; } const buf = await fs.readFile(pdfPath); // Sanity check: a real PDF starts with %PDF- if (buf.length < 5 || buf.subarray(0, 5).toString("latin1") !== "%PDF-") { console.error("[pdf] output was not a valid PDF"); return null; } return buf; } catch (e) { console.error("[pdf] render threw:", e instanceof Error ? e.message : e); return null; } finally { await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); } } /** Render letter HTML to a PDF buffer. Returns null (never throws) on failure. */ export async function renderLetterPdf(html: string, filename: string): Promise { const viaService = await renderViaPdfd(html, filename); if (viaService) return viaService; const local = await renderViaBrowser(html); if (local) return local; console.error( `[pdf] Could not render "${filename}". The letter will still send, but without a PDF attachment.` ); return null; }