import { Injectable } from '@nestjs/common'; import { AppConfig } from '../config/app-config.service'; /** A single pandoc conversion request (ADR 0009). `input` is the raw source * bytes; the converter base64-encodes it for pandoc when `from` is a binary * format (docx/odt/…) and passes it as text otherwise. */ export interface ConversionRequest { from: string; to: string; input: Buffer; standalone?: boolean; /** Inline referenced/embedded resources (images) as `data:` URIs in the * output. Used by the import pipeline (#63): pandoc-server is stateless and * will not hand back a document's media bytes any other way. */ embedResources?: boolean; /** Line-wrapping of the writer's output. Import uses `none` so a paragraph * stays on one line (no soft breaks inside image alt text or links). */ wrap?: 'none' | 'auto' | 'preserve'; /** Reference document for the docx/odt writers (issue #209, ADR 0022): * pandoc copies its page setup — including the header/footer that carry * the VS-NfD marking — into the output. Sent to pandoc-server as an * in-request file plus the `reference-doc` option. */ referenceDoc?: { name: string; bytes: Buffer }; } export interface ConversionResult { output: Buffer; mimeType: string; } export type ConversionErrorCode = | 'converter_unavailable' | 'converter_timeout' | 'conversion_failed' // The pond ran out of storage while an import stored the document's media // (#63) — final, and surfaced the same way through the worker. | 'quota_exceeded' // Vault import (#117): rejected archive / unpacked ceiling exceeded. | 'import_vault_invalid_zip' | 'import_vault_too_large'; /** A conversion failure with a stable, localizable code. `retryable` marks * the transient causes (sidecar down / timed out) the worker retries before * giving up; a genuine `conversion_failed` (bad/unsupported content) is not * retried. */ export class ConversionError extends Error { constructor( readonly code: ConversionErrorCode, readonly retryable: boolean, message?: string, ) { super(message ?? code); this.name = 'ConversionError'; } } /** The job's input bytes. Since #233 the column is nullable — the retention * job prunes finished jobs' payloads. It never touches PENDING/RUNNING rows * (incl. stale-lock recovery), so a claimed job without input was re-queued * by hand; fail it finally instead of crashing the worker. */ export function conversionInputOf(job: { input: Uint8Array | null }): Uint8Array { if (!job.input) { throw new ConversionError('conversion_failed', false, 'input payload was pruned'); } return job.input; } /** Server-side conversion limits (ADR 0009). Input is checked before the * sidecar call; output is capped while reading the response so a runaway * conversion can't exhaust memory. */ export const MAX_CONVERSION_INPUT_BYTES = 25 * 1024 * 1024; export const MAX_CONVERSION_OUTPUT_BYTES = 50 * 1024 * 1024; /** Per ADR 0009 / issue #62: a single conversion may run for at most 60 s. */ export const CONVERSION_TIMEOUT_MS = 60_000; /** Formats pandoc reads as binary — their bytes are base64-encoded in the * request `text` field; text formats are sent verbatim. */ const BINARY_INPUT_FORMATS = new Set(['docx', 'odt', 'epub', 'pptx']); /** Served content type per pandoc output format. */ const OUTPUT_MIME_TYPES: Readonly> = { html: 'text/html', markdown: 'text/markdown', gfm: 'text/markdown', commonmark: 'text/markdown', plain: 'text/plain', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', odt: 'application/vnd.oasis.opendocument.text', }; /** * Typed client for the pandoc-server sidecar (ADR 0009). Abstract so the job * worker and its tests depend on the contract, not the HTTP transport — the * queue-mechanics tests inject a fake, this real implementation is exercised * against a running container in the integration test. */ export abstract class PandocConverter { abstract convert(request: ConversionRequest): Promise; abstract reachable(): Promise; } @Injectable() export class PandocServerConverter extends PandocConverter { /** Overridable so the timeout path is testable without a 60 s wait. */ protected timeoutMs = CONVERSION_TIMEOUT_MS; constructor(private readonly config: AppConfig) { super(); } private get baseUrl(): string { return this.config.env.PANDOC_URL; } async reachable(): Promise { try { const response = await fetch(`${this.baseUrl}/version`); return response.ok; } catch { return false; } } async convert(request: ConversionRequest): Promise { if (request.input.byteLength > MAX_CONVERSION_INPUT_BYTES) { throw new ConversionError('conversion_failed', false, 'input exceeds size limit'); } const text = BINARY_INPUT_FORMATS.has(request.from) ? request.input.toString('base64') : request.input.toString('utf8'); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); let response: Response; try { response = await fetch(`${this.baseUrl}/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, from: request.from, to: request.to, standalone: request.standalone ?? true, // pandoc-server uses hyphenated option keys; unknown keys are ignored, // so these are only present when the import pipeline sets them. ...(request.embedResources ? { 'embed-resources': true } : {}), ...(request.wrap ? { wrap: request.wrap } : {}), ...(request.referenceDoc ? { 'reference-doc': request.referenceDoc.name, files: { [request.referenceDoc.name]: request.referenceDoc.bytes.toString('base64'), }, } : {}), }), signal: controller.signal, }); } catch (error) { // AbortController.abort() surfaces as an AbortError → the run timed out; // anything else means the sidecar could not be reached. if (error instanceof Error && error.name === 'AbortError') { throw new ConversionError('converter_timeout', true, 'pandoc timed out'); } throw new ConversionError('converter_unavailable', true, shortMessage(error)); } finally { clearTimeout(timer); } if (!response.ok) { // Non-200 = pandoc rejected the content (unknown format, malformed // document). Not retryable — the same input fails the same way. const detail = (await response.text().catch(() => '')).slice(0, 300); throw new ConversionError('conversion_failed', false, detail || `pandoc ${response.status}`); } const output = Buffer.from(await response.arrayBuffer()); if (output.byteLength > MAX_CONVERSION_OUTPUT_BYTES) { throw new ConversionError('conversion_failed', false, 'output exceeds size limit'); } return { output, mimeType: OUTPUT_MIME_TYPES[request.to] ?? 'application/octet-stream' }; } } function shortMessage(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return message.split('\n')[0]?.slice(0, 200) ?? 'unknown error'; }