Workflow-App/Workflow/lib/pdf-render-letter.ts

169 lines
6.3 KiB
TypeScript
Raw Normal View History

// 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<Buffer | null> {
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<Buffer | null> {
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<Buffer | null> {
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;
}