diff --git a/src/modules/certificate/certificate.service.ts b/src/modules/certificate/certificate.service.ts index 9b94362..fae70d1 100644 --- a/src/modules/certificate/certificate.service.ts +++ b/src/modules/certificate/certificate.service.ts @@ -1,4 +1,4 @@ -import { HttpStatus, Injectable, StreamableFile } from '@nestjs/common'; +import { HttpStatus, Injectable, StreamableFile, OnModuleDestroy } from '@nestjs/common'; import axios from 'axios'; import APIResponse from 'src/common/utils/response'; import { LoggerService } from 'src/common/logger/logger.service'; @@ -7,11 +7,16 @@ import { InjectRepository } from '@nestjs/typeorm'; import { UserCourseCertificate } from './entities/user_course_certificate'; import { Repository } from 'typeorm'; import { Response } from 'express'; -import puppeteer from 'puppeteer'; +import puppeteer, { Browser, Page } from 'puppeteer'; import { KafkaService } from 'src/kafka/kafka.service'; @Injectable() -export class CertificateService { +export class CertificateService implements OnModuleDestroy { + private browserInstance: Browser | null = null; + private browserLaunchPromise: Promise | null = null; + private readonly MAX_BROWSER_IDLE_TIME = 30000; // 30 seconds + private browserIdleTimeout: NodeJS.Timeout | null = null; + constructor( @InjectRepository(UserCourseCertificate) private userCourseCertificateRepository: Repository, @@ -19,6 +24,107 @@ export class CertificateService { private loggerService: LoggerService, private readonly kafkaService: KafkaService, ) {} + + /** + * Get or create a browser instance with connection pooling + * Reuses browser instance to avoid resource exhaustion + */ + private async getBrowserInstance(): Promise { + // If browser is already launched and connected, return it + if (this.browserInstance && this.browserInstance.isConnected()) { + this.resetBrowserIdleTimeout(); + return this.browserInstance; + } + + // If browser is being launched, wait for it + if (this.browserLaunchPromise) { + return this.browserLaunchPromise; + } + + // Launch new browser instance + this.browserLaunchPromise = this.launchBrowser(); + + try { + this.browserInstance = await this.browserLaunchPromise; + this.resetBrowserIdleTimeout(); + return this.browserInstance; + } finally { + this.browserLaunchPromise = null; + } + } + + /** + * Launch a new browser instance with optimized settings + */ + private async launchBrowser(): Promise { + try { + const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', // Prevents /dev/shm exhaustion + '--disable-software-rasterizer', + '--font-render-hinting=none', + '--disable-extensions', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--disable-ipc-flooding-protection', + '--single-process', // Reduces memory usage in Docker + ], + timeout: 30000, // 30 second timeout for browser launch + }); + + this.loggerService.log('Browser instance launched successfully'); + return browser; + } catch (error) { + this.loggerService.error('Failed to launch browser:', error); + throw error; + } + } + + /** + * Reset browser idle timeout - closes browser after inactivity + */ + private resetBrowserIdleTimeout(): void { + if (this.browserIdleTimeout) { + clearTimeout(this.browserIdleTimeout); + } + + this.browserIdleTimeout = setTimeout(async () => { + if (this.browserInstance) { + try { + await this.browserInstance.close(); + this.loggerService.log('Browser instance closed due to inactivity'); + } catch (error) { + this.loggerService.error('Error closing idle browser:', error); + } finally { + this.browserInstance = null; + } + } + }, this.MAX_BROWSER_IDLE_TIME); + } + + /** + * Cleanup browser instance on module destroy + */ + async onModuleDestroy() { + if (this.browserIdleTimeout) { + clearTimeout(this.browserIdleTimeout); + } + + if (this.browserInstance) { + try { + await this.browserInstance.close(); + this.loggerService.log('Browser instance closed on module destroy'); + } catch (error) { + this.loggerService.error('Error closing browser on destroy:', error); + } + } + } async generateDid(userId: string, res: Response) { let apiId = 'api.generate.did'; try { @@ -400,6 +506,7 @@ export class CertificateService { res: Response, ): Promise { const apiId = 'api.get.Certificate'; + let page: Page | null = null; try { let url = @@ -411,6 +518,7 @@ export class CertificateService { templateid: templateId, Accept: 'text/html', }, + timeout: 10000, // 10 second timeout for API call }); let htmlContent = response?.data?.replace('**Id**', credentialId); @@ -460,20 +568,14 @@ export class CertificateService { } } - // Launch Puppeteer - const browser = await puppeteer.launch({ - headless: true, // Use headless mode - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-gpu', - '--disable-dev-shm-usage', - '--disable-software-rasterizer', - '--font-render-hinting=none', - ], - }); + // Get browser instance from pool (reuses existing browser) + const browser = await this.getBrowserInstance(); - const page = await browser.newPage(); + // Create a new page for this request + page = await browser.newPage(); + + // Set timeout for page operations + page.setDefaultTimeout(30000); // 30 seconds // Set viewport for better rendering await page.setViewport({ width: 1200, height: 1600 }); @@ -481,31 +583,49 @@ export class CertificateService { // Set HTML content and wait for fonts to load await page.setContent(htmlContent, { waitUntil: 'networkidle0', // Wait for all network requests to finish (including fonts) + timeout: 30000, // 30 second timeout }); // Additional wait to ensure fonts are fully loaded await page.evaluateHandle(() => document.fonts.ready); - // Generate PDF - const pdfBuffer = await page.pdf({ - format: 'A4', - printBackground: true, - preferCSSPageSize: false, - margin: { - top: '10mm', - bottom: '10mm', - left: '0mm', - right: '0mm', - }, - }); + // Generate PDF with timeout + const pdfBuffer = await Promise.race([ + page.pdf({ + format: 'A4', + printBackground: true, + preferCSSPageSize: false, + margin: { + top: '10mm', + bottom: '10mm', + left: '0mm', + right: '0mm', + }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('PDF generation timeout')), 30000) + ), + ]); - await browser.close(); + // Close the page (but keep browser instance for reuse) + await page.close(); + page = null; // Return StreamableFile instead of calling res.end() return new StreamableFile(pdfBuffer); } catch (error) { - console.log('error: ', error); this.loggerService.error('Error generating PDF:', error); + + // Ensure page is closed even on error + if (page) { + try { + await page.close(); + } catch (closeError) { + this.loggerService.error('Error closing page on error:', closeError); + } + } + + // Re-throw error to be handled by NestJS exception filters throw error; } }