import { Injectable } from '@nestjs/common'; import { AppConfig } from '../config/app-config.service'; import { MAX_CONVERSION_OUTPUT_BYTES } from './pandoc.converter'; export type RenderErrorCode = 'renderer_unavailable' | 'render_failed'; /** A PDF render failure with a stable, localizable code. Like the converter, * an unreachable sidecar is retryable; a render Gotenberg refuses is not. */ export class RenderError extends Error { constructor( readonly code: RenderErrorCode, readonly retryable: boolean, message?: string, ) { super(message ?? code); this.name = 'RenderError'; } } /** A single-page footer that prints "n / total" bottom-centre — Gotenberg's * Chromium route substitutes the `pageNumber`/`totalPages` spans. */ const FOOTER_HTML = '
' + ' / ' + '
'; /** Per ADR 0009: a single render may run for at most 60 s. */ const RENDER_TIMEOUT_MS = 60_000; /** * Typed client for the Gotenberg PDF sidecar (ADR 0009, issue #67). Abstract so * the export worker and its tests depend on the contract, not the HTTP * transport — tests inject a fake, this real implementation is exercised against * a running container in the integration test. */ export abstract class GotenbergRenderer { /** Render a standalone HTML document (fonts/images already inlined) to PDF. */ abstract renderHtmlToPdf(html: string): Promise; abstract reachable(): Promise; } @Injectable() export class GotenbergHttpRenderer extends GotenbergRenderer { protected timeoutMs = RENDER_TIMEOUT_MS; constructor(private readonly config: AppConfig) { super(); } private get baseUrl(): string { return this.config.env.GOTENBERG_URL; } async reachable(): Promise { try { const response = await fetch(`${this.baseUrl}/health`); return response.ok; } catch { return false; } } async renderHtmlToPdf(html: string): Promise { const form = new FormData(); // Gotenberg's Chromium route requires the main document to be `index.html`. form.append('files', new Blob([html], { type: 'text/html' }), 'index.html'); form.append('files', new Blob([FOOTER_HTML], { type: 'text/html' }), 'footer.html'); // Page geometry: A4 with room at the bottom for the page-number footer. The // document's own `@page`/print CSS controls the rest of the layout. form.append('paperWidth', '8.27'); form.append('paperHeight', '11.7'); form.append('marginTop', '0.6'); form.append('marginBottom', '0.8'); form.append('marginLeft', '0.7'); form.append('marginRight', '0.7'); form.append('printBackground', 'true'); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); let response: Response; try { response = await fetch(`${this.baseUrl}/forms/chromium/convert/html`, { method: 'POST', body: form, signal: controller.signal, }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new RenderError('renderer_unavailable', true, 'gotenberg timed out'); } throw new RenderError('renderer_unavailable', true, shortMessage(error)); } finally { clearTimeout(timer); } if (!response.ok) { const detail = (await response.text().catch(() => '')).slice(0, 300); throw new RenderError('render_failed', false, detail || `gotenberg ${response.status}`); } const output = Buffer.from(await response.arrayBuffer()); if (output.byteLength > MAX_CONVERSION_OUTPUT_BYTES) { throw new RenderError('render_failed', false, 'PDF exceeds size limit'); } return output; } } function shortMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return message.split('\n')[0]?.slice(0, 200) ?? 'unknown error'; }