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';
}