169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
|
|
// 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;
|
||
|
|
}
|