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
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:
parent
54316d45cf
commit
76b2179be9
3 changed files with 265 additions and 179 deletions
92
src/utils/pdf-handler.ts
Normal file
92
src/utils/pdf-handler.ts
Normal 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_FULL→503, PDF_TIMEOUT→504, generic→500)
|
||||
* - 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue