import fs from "fs"; import { Browser } from "puppeteer"; /** * Pick the first Chromium executable that actually exists on disk. * The previous `A || B || C` chain was dead: the second operand * ("/usr/bin/chromium-browser") is always truthy, so the third * ("/usr/bin/chromium") was never reached and launch failed on hosts * that only ship the `chromium` binary. We now probe each candidate * with fs.existsSync and fall through to the next. */ function resolveChromiumPath(): string | undefined { const candidates = [ process.env.CHROMIUM_PATH, "/usr/bin/chromium-browser", "/usr/bin/chromium", ].filter((p): p is string => Boolean(p)); for (const candidate of candidates) { try { if (fs.existsSync(candidate)) return candidate; } catch { // existsSync should not throw, but if it does treat as "not found" // and continue probing the next candidate. } } // Nothing on disk matched — fall back to the configured env value (if any) // so puppeteer-core surfaces a meaningful launch error instead of "undefined". return process.env.CHROMIUM_PATH || undefined; } let browser: Browser | null = null; let launching: Promise | null = null; async function getBrowser(): Promise { if (browser && browser.connected) return browser; if (launching) return launching; launching = (async () => { // Try puppeteer (bundles Chromium), fall back to puppeteer-core (system Chromium) try { const puppeteer = await import("puppeteer"); browser = await puppeteer.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], }); } catch { const core = await import("puppeteer-core"); const executablePath = resolveChromiumPath(); browser = await core.default.launch({ headless: true, executablePath, args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], }); } return browser!; })(); try { return await launching; } finally { launching = null; } } export interface HtmlToPdfOptions { /** * Chromium footerTemplate HTML rendered in the bottom margin of EVERY page * — the only true repeating footer Puppeteer supports (CSS @page margin * boxes are not implemented in Chromium). Styles must be inline and the * font-size explicit (Chromium defaults it to 0); `.pageNumber` / * `.totalPages` spans are substituted by Chromium. When set, the bottom * margin grows to make room and an empty headerTemplate suppresses * Chromium's default date/title header. */ footerTemplate?: string; /** * Chromium headerTemplate HTML rendered in the top margin of EVERY page * (same rules as footerTemplate: inline styles, explicit font-size; images * must be data: URLs). When set, the top margin grows to 32mm to make room * — the document body must NOT render its own header then, or page 1 shows * it twice. NOTE: Chromium lays pages out by the document's CSS @page * margins when present (they override this option) — keep them in sync. */ headerTemplate?: string; } export async function htmlToPdf( html: string, options: HtmlToPdfOptions = {}, ): Promise { const b = await getBrowser(); const page = await b.newPage(); // Per-request timeouts so one stuck render (e.g. image tag pointing at a // hung host) cannot starve every subsequent PDF request. networkidle0 is // intentionally NOT used — it waits for 500ms of zero network connections // and is the original source of the indefinite-hang bug. try { await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 10_000, }); const pdf = await page.pdf({ format: "A4", printBackground: true, margin: { top: options.headerTemplate ? "32mm" : "10mm", bottom: options.footerTemplate ? "18mm" : "10mm", left: "10mm", right: "10mm", }, ...(options.footerTemplate || options.headerTemplate ? { displayHeaderFooter: true, headerTemplate: options.headerTemplate || "", footerTemplate: options.footerTemplate || "", } : {}), timeout: 15_000, }); return Buffer.from(pdf); } finally { try { await page.close(); } catch { // If a stuck page won't close, only close the whole browser if it // is actually disconnected — otherwise we just spent 1-2s relaunching // Chromium on every subsequent PDF request. if (!browser?.connected) { await closeBrowser(); } } } } export async function closeBrowser(): Promise { if (browser) { await browser.close(); browser = null; } }