Files
Reactive-Resume/src/integrations/orpc/services/printer.ts
T
2026-03-17 23:42:35 +01:00

379 lines
14 KiB
TypeScript

import dns from "node:dns/promises";
import { isIP } from "node:net";
import { ORPCError } from "@orpc/server";
import type { InferSelectModel } from "drizzle-orm";
import puppeteer, { type Browser, type ConnectOptions, type Page } from "puppeteer-core";
import type { schema } from "@/integrations/drizzle";
import { pageDimensionsAsPixels } from "@/schema/page";
import { printMarginTemplates } from "@/schema/templates";
import { env } from "@/utils/env";
import { generatePrinterToken } from "@/utils/printer-token";
import { getStorageService, uploadFile } from "./storage";
const SCREENSHOT_TTL = 1000 * 60 * 60 * 6; // 6 hours
// Singleton browser instance for connection reuse
let browserInstance: Browser | null = null;
async function normalizePrinterEndpoint(printerEndpoint: string): Promise<URL> {
// Convert endpoint hostname to IP when using chromedp
// https://github.com/amruthpillai/reactive-resume/issues/2681
const endpoint = new URL(printerEndpoint);
if (!isIP(endpoint.hostname) && !endpoint.protocol.startsWith("ws")) {
const { address } = await dns.lookup(endpoint.hostname);
endpoint.hostname = address;
}
return endpoint;
}
async function getBrowser(): Promise<Browser> {
// Reuse existing connected browser if available
if (browserInstance?.connected) return browserInstance;
const args = ["--disable-dev-shm-usage", "--disable-features=LocalNetworkAccessChecks,site-per-process,FedCm"];
const endpoint = await normalizePrinterEndpoint(env.PRINTER_ENDPOINT);
const isWebSocket = endpoint.protocol.startsWith("ws");
const connectOptions: ConnectOptions = { acceptInsecureCerts: true };
endpoint.searchParams.append("launch", JSON.stringify({ args }));
if (isWebSocket) connectOptions.browserWSEndpoint = endpoint.toString();
else connectOptions.browserURL = endpoint.toString();
browserInstance = await puppeteer.connect(connectOptions);
return browserInstance;
}
async function closeBrowser(): Promise<void> {
if (browserInstance?.connected) {
await browserInstance.close();
browserInstance = null;
}
}
// Close browser on process termination
process.on("SIGINT", async () => {
await closeBrowser();
process.exit(0);
});
process.on("SIGTERM", async () => {
await closeBrowser();
process.exit(0);
});
export const printerService = {
healthcheck: async (): Promise<object> => {
const headers = new Headers({ Accept: "application/json" });
const endpoint = await normalizePrinterEndpoint(env.PRINTER_ENDPOINT);
endpoint.protocol = endpoint.protocol.replace("ws", "http");
endpoint.pathname = "/json/version";
const response = await fetch(endpoint, { headers });
const data = await response.json();
return data;
},
/**
* Generates a PDF from a resume and uploads it to storage.
*
* The process:
* 1. Clean up any existing PDF for this resume
* 2. Navigate to the printer route which renders the resume
* 3. Calculate PDF margins (some templates require margins to be applied via PDF)
* 4. Adjust CSS variables so content fits within printable area (accounting for margins)
* 5. Add page break CSS to ensure each visual resume page becomes a PDF page
* 6. Generate the PDF with proper dimensions and margins
* 7. Upload to storage and return the URL
*/
printResumeAsPDF: async (
input: Pick<InferSelectModel<typeof schema.resume>, "id" | "data" | "userId">,
): Promise<string> => {
const { id, data, userId } = input;
// Step 1: Delete any existing PDF for this resume to ensure fresh generation
const storageService = getStorageService();
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
await storageService.delete(pdfPrefix);
// Step 2: Prepare the URL and authentication for the printer route
// The printer route renders the resume in a format optimized for PDF generation
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const format = data.metadata.page.format;
const locale = data.metadata.page.locale;
const template = data.metadata.template;
// Generate a secure token to authenticate the printer request
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
// Step 3: Calculate print paddings for templates that disable CSS padding in print mode.
// We render these margins inside the page (not via Puppeteer's PDF margins), so the margin
// area matches the resume background color instead of staying white.
let pagePaddingX = 0;
let pagePaddingY = 0;
if (printMarginTemplates.includes(template)) {
pagePaddingX = data.metadata.page.marginX;
pagePaddingY = data.metadata.page.marginY;
}
let browser: Browser | null = null;
let page: Page | null = null;
try {
// Step 4: Connect to the browser and navigate to the printer route
browser = await getBrowser();
// Set locale cookie so the resume renders in the correct language
await browser.setCookie({ name: "locale", value: locale, domain });
page = await browser.newPage();
// Wait for the page to fully load (network idle + custom loaded attribute)
await page.emulateMediaType("print");
await page.setViewport(pageDimensionsAsPixels[format]);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
// Step 5: Adjust the DOM for proper PDF pagination
// This runs in the browser context to modify CSS before PDF generation
// For free-form: measure actual content height, don't add page breaks
// For A4/Letter: adjust page height for margins, add page breaks
const isFreeForm = format === "free-form";
const contentHeight = await page.evaluate(
(
pagePaddingX: number,
pagePaddingY: number,
isFreeForm: boolean,
minPageHeight: number,
backgroundColor: string,
) => {
const root = document.documentElement;
const body = document.body;
const pageElements = document.querySelectorAll("[data-page-index]");
const pageContentElements = document.querySelectorAll(".page-content");
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
// Ensure PDF margins inherit the resume background color instead of defaulting to white.
root.style.backgroundColor = backgroundColor;
body.style.backgroundColor = backgroundColor;
root.style.margin = "0";
body.style.margin = "0";
root.style.padding = "0";
body.style.padding = "0";
for (const el of pageElements) {
const pageWrapper = el as HTMLElement;
const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null;
pageWrapper.style.backgroundColor = backgroundColor;
pageWrapper.style.breakInside = "auto";
if (pageSurface) pageSurface.style.backgroundColor = backgroundColor;
}
// Apply print-only margins as padding inside each page's content surface.
if (pagePaddingX > 0 || pagePaddingY > 0) {
for (const el of pageContentElements) {
const pageContent = el as HTMLElement;
pageContent.style.boxSizing = "border-box";
// Ensure padding is repeated on every printed fragment when content
// flows across physical PDF pages (not just the first fragment).
pageContent.style.boxDecorationBreak = "clone";
pageContent.style.setProperty("-webkit-box-decoration-break", "clone");
if (pagePaddingX > 0) {
pageContent.style.paddingLeft = `${pagePaddingX}pt`;
pageContent.style.paddingRight = `${pagePaddingX}pt`;
}
if (pagePaddingY > 0) {
pageContent.style.paddingTop = `${pagePaddingY}pt`;
pageContent.style.paddingBottom = `${pagePaddingY}pt`;
}
}
}
if (isFreeForm) {
const numberOfPages = pageElements.length;
// Add margin between pages (except the last one)
for (let i = 0; i < numberOfPages - 1; i++) {
const pageEl = pageElements[i] as HTMLElement;
if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`;
}
// Now measure the total height (margins are now part of the DOM)
let totalHeight = 0;
for (const el of pageElements) {
const pageEl = el as HTMLElement;
// offsetHeight includes padding and border, but not margin
const style = getComputedStyle(pageEl);
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
totalHeight += pageEl.offsetHeight + marginBottom;
}
return Math.max(totalHeight, minPageHeight);
}
// For A4/Letter
const heightValue = minPageHeight;
// Keep page height fixed and let in-page padding (if any) define content bounds.
const newHeight = `${heightValue}px`;
if (container) container.style.setProperty("--page-height", newHeight);
root.style.setProperty("--page-height", newHeight);
// Add page break CSS to each resume page element (identified by data-page-index attribute)
// This ensures each visual resume page starts a new PDF page
for (const el of pageElements) {
const element = el as HTMLElement;
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
// Force a page break before each page except the first
if (index > 0) {
element.style.breakBefore = "page";
element.style.pageBreakBefore = "always";
}
// Allow content within a page to break naturally if it overflows
// (e.g., if a single page has more content than fits on one PDF page)
element.style.breakInside = "auto";
}
return null; // Fixed height from pageDimensionsAsPixels for A4/Letter
},
pagePaddingX,
pagePaddingY,
isFreeForm,
pageDimensionsAsPixels[format].height,
data.metadata.design.colors.background,
);
// Step 6: Generate the PDF with the specified dimensions and margins
// For free-form: use measured content height (with minimum constraint)
// For A4/Letter: use fixed dimensions from pageDimensionsAsPixels
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
const pdfBuffer = await page.pdf({
width: `${pageDimensionsAsPixels[format].width}px`,
height: `${pdfHeight}px`,
tagged: true, // Adds accessibility tags to the PDF
waitForFonts: true, // Ensures all fonts are loaded before rendering
printBackground: true, // Includes background colors and images
margin: {
bottom: 0,
top: 0,
right: 0,
left: 0,
},
});
// Step 7: Upload the generated PDF to storage
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
});
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
} finally {
if (page) await page.close().catch(() => null);
}
},
getResumeScreenshot: async (
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data" | "updatedAt">,
): Promise<string> => {
const { id, userId, data, updatedAt } = input;
const storageService = getStorageService();
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
const existingScreenshots = await storageService.list(screenshotPrefix);
const now = Date.now();
const resumeUpdatedAt = updatedAt.getTime();
if (existingScreenshots.length > 0) {
const sortedFiles = existingScreenshots
.map((path) => {
const filename = path.split("/").pop();
const match = filename?.match(/^(\d+)\.webp$/);
return match ? { path, timestamp: Number(match[1]) } : null;
})
.filter((item): item is { path: string; timestamp: number } => item !== null)
.sort((a, b) => b.timestamp - a.timestamp);
if (sortedFiles.length > 0) {
const latest = sortedFiles[0];
const age = now - latest.timestamp;
// Return existing screenshot if it's still fresh (within TTL)
if (age < SCREENSHOT_TTL) return new URL(latest.path, env.APP_URL).toString();
// Screenshot is stale (past TTL), but only regenerate if the resume
// was updated after the screenshot was taken. If the resume hasn't
// changed, keep using the existing screenshot to avoid unnecessary work.
if (resumeUpdatedAt <= latest.timestamp) {
return new URL(latest.path, env.APP_URL).toString();
}
// Resume was updated after the screenshot - delete old ones and regenerate
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
}
}
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const locale = data.metadata.page.locale;
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
let browser: Browser | null = null;
let page: Page | null = null;
try {
browser = await getBrowser();
await browser.setCookie({ name: "locale", value: locale, domain });
page = await browser.newPage();
await page.setViewport(pageDimensionsAsPixels.a4);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(screenshotBuffer),
contentType: "image/webp",
type: "screenshot",
});
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
} finally {
if (page) await page.close().catch(() => null);
}
},
};