import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Logger, OnApplicationShutdown } from '@nestjs/common'; import { Job } from 'bullmq'; import { performance } from 'node:perf_hooks'; import * as puppeteer from 'puppeteer'; import * as pdfPrinter from 'pdf-to-printer'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { PrintHtmlDto } from '../dto/print-html.dto'; @Processor('printer') export class PrinterProcessor extends WorkerHost implements OnApplicationShutdown { private readonly logger = new Logger(PrinterProcessor.name); private browser: puppeteer.Browser | undefined; private browserLaunching: Promise | undefined; private async getBrowser(): Promise { if (this.browser?.isConnected()) return this.browser; if (this.browserLaunching) return this.browserLaunching; this.browserLaunching = puppeteer .launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }) .then((browser) => { this.browser = browser; return browser; }) .finally(() => { this.browserLaunching = undefined; }); return this.browserLaunching; } async onApplicationShutdown(): Promise { try { await this.browser?.close(); } catch (err) { this.logger.warn(`Failed to close Puppeteer browser: ${String(err)}`); } finally { this.browser = undefined; } } private logJob( level: 'log' | 'warn' | 'error' | 'debug', payload: Record, error?: unknown, ): void { const message = JSON.stringify(payload); if (level === 'error') { const stack = error instanceof Error ? error.stack : undefined; this.logger.error(message, stack); return; } if (level === 'warn') { this.logger.warn(message); return; } if (level === 'debug') { this.logger.debug(message); return; } this.logger.log(message); } async process(job: Job): Promise { const data = job.data; const printerName = data?.printerName; const jobCtx = { jobId: job.id, jobName: job.name, printerName, }; const tempFilePath = path.join( os.tmpdir(), `print-${job.id}-${Date.now()}.pdf`, ); let page: puppeteer.Page | undefined; let phase: 'render' | 'pdf' | 'spool' | 'unknown' = 'unknown'; const durationsMs: { render?: number; pdf?: number; spool?: number; total?: number; } = {}; const totalStart = performance.now(); this.logJob('log', { event: 'print_job_start', ...jobCtx, width: data?.width, height: data?.height, }); try { const browser = await this.getBrowser(); page = await browser.newPage(); const width = data.width || '80mm'; const height = data.height; const fullHtml = ` ${data.html} `; phase = 'render'; const renderStart = performance.now(); await page.setContent(fullHtml, { waitUntil: 'networkidle0' }); durationsMs.render = performance.now() - renderStart; this.logJob('log', { event: 'print_job_phase', phase, durationMs: durationsMs.render, ...jobCtx, }); const pdfOptions: any = { path: tempFilePath, printBackground: true, width: width, margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' }, }; if (height) pdfOptions.height = height; phase = 'pdf'; const pdfStart = performance.now(); await page.pdf(pdfOptions); durationsMs.pdf = performance.now() - pdfStart; this.logJob('log', { event: 'print_job_phase', phase, durationMs: durationsMs.pdf, ...jobCtx, }); await page.close(); page = undefined; phase = 'spool'; const spoolStart = performance.now(); await pdfPrinter.print(tempFilePath, { printer: data.printerName, silent: true, }); durationsMs.spool = performance.now() - spoolStart; this.logJob('log', { event: 'print_job_phase', phase, durationMs: durationsMs.spool, ...jobCtx, }); durationsMs.total = performance.now() - totalStart; this.logJob('log', { event: 'print_job_success', ...jobCtx, durationsMs, }); return { status: 'success', jobId: job.id }; } catch (error) { if (page) { try { await page.close(); } catch { // ignore } } const message = error instanceof Error ? error.message : String(error); durationsMs.total = performance.now() - totalStart; this.logJob( 'error', { event: 'print_job_error', ...jobCtx, phase, durationsMs, errorMessage: message, errorType: error instanceof Error ? error.name : typeof error, }, error, ); // If the shared browser crashed/closed, reset it so next job can relaunch. if (/Target closed|Browser closed|Protocol error/i.test(message)) { try { await this.browser?.close(); } catch { // ignore } finally { this.browser = undefined; } } throw error; } finally { if (fs.existsSync(tempFilePath)) { fs.unlinkSync(tempFilePath); } } } }