Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 150 additions & 30 deletions src/modules/certificate/certificate.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,18 +7,124 @@ 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<Browser> | null = null;
private readonly MAX_BROWSER_IDLE_TIME = 30000; // 30 seconds
private browserIdleTimeout: NodeJS.Timeout | null = null;

constructor(
@InjectRepository(UserCourseCertificate)
private userCourseCertificateRepository: Repository<UserCourseCertificate>,
private configService: ConfigService,
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<Browser> {
// 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<Browser> {
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 {
Expand Down Expand Up @@ -400,6 +506,7 @@ export class CertificateService {
res: Response,
): Promise<StreamableFile> {
const apiId = 'api.get.Certificate';
let page: Page | null = null;

try {
let url =
Expand All @@ -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);

Expand Down Expand Up @@ -460,52 +568,64 @@ 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 });

// 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<never>((_, 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;
}
}
Expand Down