refactor: extract shared PDF route handler to eliminate convert route duplication
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m19s

- New src/utils/pdf-handler.ts with handlePdfRoute() helper
- Handles: content-type validation, PDF option validation, slot acquire/release, error mapping, response headers
- Refactored convert.ts from 388 to 233 lines (40% reduction)
- 10 TDD tests for the new helper (RED→GREEN verified)
- All 618 tests passing, zero tsc --noEmit errors
This commit is contained in:
DocFast CEO 2026-03-09 20:07:27 +01:00
parent 54316d45cf
commit 76b2179be9
3 changed files with 265 additions and 179 deletions

92
src/utils/pdf-handler.ts Normal file
View file

@ -0,0 +1,92 @@
import { Request, Response } from "express";
import type { PaperFormat } from "puppeteer";
import logger from "../services/logger.js";
import { errorMessage } from "./errors.js";
import { sanitizeFilename } from "./sanitize.js";
import { validatePdfOptions } from "./pdf-options.js";
export interface PdfRenderResult {
pdf: Buffer;
durationMs: number;
filename: string;
}
export interface SanitizedPdfOptions {
format?: PaperFormat;
landscape?: boolean;
margin?: { top?: string; right?: string; bottom?: string; left?: string };
printBackground?: boolean;
headerTemplate?: string;
footerTemplate?: string;
displayHeaderFooter?: boolean;
scale?: number;
pageRanges?: string;
preferCSSPageSize?: boolean;
width?: string;
height?: string;
}
/**
* Shared handler for PDF generation routes. Handles:
* - Content-Type validation (415)
* - PDF option validation (400)
* - Concurrency slot acquire/release
* - Error mapping (QUEUE_FULL503, PDF_TIMEOUT504, generic500)
* - Response headers (Content-Type, Content-Disposition, X-Render-Time)
*
* The renderFn receives sanitized PDF options and must return the PDF buffer + metadata.
*/
export async function handlePdfRoute(
req: Request,
res: Response,
renderFn: (sanitizedOptions: SanitizedPdfOptions) => Promise<PdfRenderResult | null>
): Promise<void> {
let slotAcquired = false;
try {
// Reject non-JSON content types
const ct = req.headers["content-type"] || "";
if (!ct.includes("application/json")) {
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
return;
}
// Validate PDF options
const validation = validatePdfOptions(req.body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
// Acquire concurrency slot
if (req.acquirePdfSlot) {
await req.acquirePdfSlot();
slotAcquired = true;
}
const result = await renderFn(validation.sanitized!);
if (!result) return; // renderFn already sent a response (e.g., 400 for missing field)
const { pdf, durationMs, filename } = result;
const safeName = sanitizeFilename(filename || "document.pdf");
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${safeName}"`);
res.setHeader("X-Render-Time", String(durationMs));
res.send(pdf);
} catch (err: unknown) {
logger.error({ err }, "PDF route error");
const msg = errorMessage(err);
if (msg === "QUEUE_FULL") {
res.status(503).json({ error: "Server busy — too many concurrent PDF generations. Please try again in a few seconds." });
return;
}
if (msg === "PDF_TIMEOUT") {
res.status(504).json({ error: "PDF generation timed out." });
return;
}
res.status(500).json({ error: "PDF generation failed." });
} finally {
if (slotAcquired && req.releasePdfSlot) {
req.releasePdfSlot();
}
}
}