design nosepass template, add tests, add template previews

This commit is contained in:
Amruth Pillai
2023-11-17 08:31:12 +01:00
parent 0b4cb71320
commit 34247f13b6
92 changed files with 24440 additions and 35518 deletions

View File

@ -40,7 +40,7 @@ export const configSchema = z.object({
REDIS_URL: z.string().url().startsWith("redis://").optional(),
// Sentry
SENTRY_DSN: z.string().url().startsWith("https://").optional(),
VITE_SENTRY_DSN: z.string().url().startsWith("https://").optional(),
// Crowdin (Optional)
CROWDIN_PROJECT_ID: z.coerce.number().optional(),

View File

@ -1,6 +1,5 @@
import { Injectable } from "@nestjs/common";
import { HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus";
import { withTimeout } from "@reactive-resume/utils";
import { PrinterService } from "../printer/printer.service";
@ -12,8 +11,7 @@ export class BrowserHealthIndicator extends HealthIndicator {
async isHealthy(): Promise<HealthIndicatorResult> {
try {
const version = await withTimeout(this.printerService.getVersion(), 5000);
// const version = await this.printerService.getVersion();
const version = await this.printerService.getVersion();
return this.getStatus("browser", true, { version });
} catch (error) {

View File

@ -23,7 +23,7 @@ async function bootstrap() {
// Sentry
// Error Reporting and Performance Monitoring
const sentryDsn = configService.get("SENTRY_DSN");
const sentryDsn = configService.get("VITE_SENTRY_DSN");
if (sentryDsn) {
const express = app.getHttpAdapter().getInstance();

View File

@ -4,7 +4,7 @@ import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import fontkit from "@pdf-lib/fontkit";
import { ResumeDto } from "@reactive-resume/dto";
import { getFontUrls, withTimeout } from "@reactive-resume/utils";
import { getFontUrls } from "@reactive-resume/utils";
import { ErrorMessage } from "@reactive-resume/utils";
import retry from "async-retry";
import { PDFDocument } from "pdf-lib";
@ -14,8 +14,6 @@ import { Config } from "../config/schema";
import { StorageService } from "../storage/storage.service";
import { UtilsService } from "../utils/utils.service";
const PRINTER_TIMEOUT = 10000; // 10 seconds
@Injectable()
export class PrinterService {
private readonly logger = new Logger(PrinterService.name);
@ -55,11 +53,11 @@ export class PrinterService {
async () => {
const start = performance.now();
const url = await retry(() => withTimeout(this.generateResume(resume), PRINTER_TIMEOUT), {
retries: 2,
const url = await retry(() => this.generateResume(resume), {
retries: 3,
randomize: true,
onRetry: (_, attempt) => {
this.logger.debug(`Print Resume: retry attempt #${attempt}`);
this.logger.log(`Retrying to print resume #${resume.id}, attempt #${attempt}`);
},
});
@ -76,117 +74,138 @@ export class PrinterService {
async printPreview(resume: ResumeDto) {
return this.utils.getCachedOrSet(
`user:${resume.userId}:storage:previews:${resume.id}`,
async () => withTimeout(this.generatePreview(resume), PRINTER_TIMEOUT),
async () => {
const start = performance.now();
const url = await retry(() => this.generatePreview(resume), {
retries: 3,
randomize: true,
onRetry: (_, attempt) => {
this.logger.log(
`Retrying to generate preview of resume #${resume.id}, attempt #${attempt}`,
);
},
});
const duration = Number(performance.now() - start).toFixed(0);
this.logger.debug(`Chrome took ${duration}ms to generate preview`);
return url;
},
);
}
async generateResume(resume: ResumeDto) {
const browser = await this.getBrowser();
const page = await browser.newPage();
try {
const browser = await this.getBrowser();
const page = await browser.newPage();
let url = this.utils.getUrl();
const publicUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
let url = this.utils.getUrl();
const publicUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
if ([publicUrl, storageUrl].some((url) => url.includes("localhost"))) {
// Switch client URL from `localhost` to `host.docker.internal` in development
// This is required because the browser is running in a container and the client is running on the host machine.
url = url.replace("localhost", "host.docker.internal");
if ([publicUrl, storageUrl].some((url) => url.includes("localhost"))) {
// Switch client URL from `localhost` to `host.docker.internal` in development
// This is required because the browser is running in a container and the client is running on the host machine.
url = url.replace("localhost", "host.docker.internal");
await page.setRequestInterception(true);
await page.setRequestInterception(true);
// Intercept requests of `localhost` to `host.docker.internal` in development
page.on("request", (request) => {
if (request.url().startsWith(storageUrl)) {
const modifiedUrl = request.url().replace("localhost", `host.docker.internal`);
// Intercept requests of `localhost` to `host.docker.internal` in development
page.on("request", (request) => {
if (request.url().startsWith(storageUrl)) {
const modifiedUrl = request.url().replace("localhost", `host.docker.internal`);
request.continue({ url: modifiedUrl });
} else {
request.continue();
}
});
request.continue({ url: modifiedUrl });
} else {
request.continue();
}
});
}
// Set the data of the resume to be printed in the browser's session storage
const numPages = resume.data.metadata.layout.length;
await page.evaluateOnNewDocument((data) => {
window.localStorage.setItem("resume", JSON.stringify(data));
}, resume.data);
await page.goto(`${url}/artboard/preview`, { waitUntil: "networkidle0" });
const pagesBuffer: Buffer[] = [];
const processPage = async (index: number) => {
const pageElement = await page.$(`[data-page="${index}"]`);
const width = (await (await pageElement?.getProperty("scrollWidth"))?.jsonValue()) ?? 0;
const height = (await (await pageElement?.getProperty("scrollHeight"))?.jsonValue()) ?? 0;
const tempHtml = await page.evaluate((element: HTMLDivElement) => {
const clonedElement = element.cloneNode(true) as HTMLDivElement;
const tempHtml = document.body.innerHTML;
document.body.innerHTML = `${clonedElement.outerHTML}`;
return tempHtml;
}, pageElement);
pagesBuffer.push(await page.pdf({ width, height, printBackground: true }));
await page.evaluate((tempHtml: string) => {
document.body.innerHTML = tempHtml;
}, tempHtml);
};
// Loop through all the pages and print them, by first displaying them, printing the PDF and then hiding them back
for (let index = 1; index <= numPages; index++) {
await processPage(index);
}
// Using 'pdf-lib', merge all the pages from their buffers into a single PDF
const pdf = await PDFDocument.create();
pdf.registerFontkit(fontkit);
// Get information about fonts used in the resume from the metadata
const fontData = resume.data.metadata.typography.font;
const fontUrls = getFontUrls(fontData.family, fontData.variants);
// Load all the fonts from the URLs using HttpService
const responses = await Promise.all(
fontUrls.map((url) =>
this.httpService.axiosRef.get(url, {
responseType: "arraybuffer",
}),
),
);
const fontsBuffer = responses.map((response) => response.data as ArrayBuffer);
// Embed all the fonts in the PDF
await Promise.all(fontsBuffer.map((buffer) => pdf.embedFont(buffer)));
for (let index = 0; index < pagesBuffer.length; index++) {
const page = await PDFDocument.load(pagesBuffer[index]);
const [copiedPage] = await pdf.copyPages(page, [0]);
pdf.addPage(copiedPage);
}
// Save the PDF to storage and return the URL to download the resume
// Store the URL in cache for future requests, under the previously generated hash digest
const buffer = Buffer.from(await pdf.save());
// This step will also save the resume URL in cache
const resumeUrl = await this.storageService.uploadObject(
resume.userId,
"resumes",
buffer,
resume.id,
);
// Close all the pages and disconnect from the browser
await page.close();
browser.disconnect();
return resumeUrl;
} catch (error) {
console.trace(error);
}
// Set the data of the resume to be printed in the browser's session storage
const numPages = resume.data.metadata.layout.length;
await page.evaluateOnNewDocument((data) => {
window.localStorage.setItem("resume", JSON.stringify(data));
}, resume.data);
await page.goto(`${url}/artboard/preview`, { waitUntil: "networkidle0" });
const pagesBuffer: Buffer[] = [];
const processPage = async (index: number) => {
const pageElement = await page.$(`[data-page="${index}"]`);
const width = (await (await pageElement?.getProperty("scrollWidth"))?.jsonValue()) ?? 0;
const height = (await (await pageElement?.getProperty("scrollHeight"))?.jsonValue()) ?? 0;
const tempHtml = await page.evaluate((element: HTMLDivElement) => {
const clonedElement = element.cloneNode(true) as HTMLDivElement;
const tempHtml = document.body.innerHTML;
document.body.innerHTML = `${clonedElement.outerHTML}`;
return tempHtml;
}, pageElement);
pagesBuffer.push(await page.pdf({ width, height, printBackground: true }));
await page.evaluate((tempHtml: string) => {
document.body.innerHTML = tempHtml;
}, tempHtml);
};
// Loop through all the pages and print them, by first displaying them, printing the PDF and then hiding them back
for (let index = 1; index <= numPages; index++) {
await processPage(index);
}
// Using 'pdf-lib', merge all the pages from their buffers into a single PDF
const pdf = await PDFDocument.create();
pdf.registerFontkit(fontkit);
// Get information about fonts used in the resume from the metadata
const fontData = resume.data.metadata.typography.font;
const fontUrls = getFontUrls(fontData.family, fontData.variants);
// Load all the fonts from the URLs using HttpService
const responses = await Promise.all(
fontUrls.map((url) =>
this.httpService.axiosRef.get(url, {
responseType: "arraybuffer",
}),
),
);
const fontsBuffer = responses.map((response) => response.data as ArrayBuffer);
// Embed all the fonts in the PDF
await Promise.all(fontsBuffer.map((buffer) => pdf.embedFont(buffer)));
for (let index = 0; index < pagesBuffer.length; index++) {
const page = await PDFDocument.load(pagesBuffer[index]);
const copiedPage = await pdf.copyPages(page, [0]);
pdf.addPage(copiedPage[0]);
}
// Save the PDF to storage and return the URL to download the resume
// Store the URL in cache for future requests, under the previously generated hash digest
const buffer = Buffer.from(await pdf.save());
// This step will also save the resume URL in cache
const resumeUrl = await this.storageService.uploadObject(
resume.userId,
"resumes",
buffer,
resume.id,
);
// Close all the pages and disconnect from the browser
await page.close();
browser.disconnect();
return resumeUrl;
}
async generatePreview(resume: ResumeDto) {

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
import { defaultResumeData, ResumeData } from "@reactive-resume/schema";
@ -18,7 +18,6 @@ import { UtilsService } from "../utils/utils.service";
@Injectable()
export class ResumeService {
private readonly redis: Redis;
private readonly logger = new Logger(ResumeService.name);
constructor(
private readonly prisma: PrismaService,