Add PDF file caching

This commit is contained in:
Krisjanis Lejejs
2022-10-05 23:18:10 +03:00
parent 9b1f3eda05
commit 78a32961d7
9 changed files with 77 additions and 66 deletions

View File

@ -13,6 +13,7 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } from '@mui/material'; import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } from '@mui/material';
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs';
import { get } from 'lodash'; import { get } from 'lodash';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -67,8 +68,9 @@ const ArtboardController: React.FC<ReactZoomPanPinchRef> = ({ zoomIn, zoomOut, c
const slug = get(resume, 'slug'); const slug = get(resume, 'slug');
const username = get(resume, 'user.username'); const username = get(resume, 'user.username');
const updatedAt = get(resume, 'updatedAt');
const url = await mutateAsync({ username, slug }); const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
download(url); download(url);
}; };

View File

@ -1,5 +1,6 @@
import { PictureAsPdf, Schema } from '@mui/icons-material'; import { PictureAsPdf, Schema } from '@mui/icons-material';
import { List, ListItem, ListItemButton, ListItemText } from '@mui/material'; import { List, ListItem, ListItemButton, ListItemText } from '@mui/material';
import dayjs from 'dayjs';
import get from 'lodash/get'; import get from 'lodash/get';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
@ -45,8 +46,9 @@ const Export = () => {
const slug = get(resume, 'slug'); const slug = get(resume, 'slug');
const username = get(resume, 'user.username'); const username = get(resume, 'user.username');
const updatedAt = get(resume, 'updatedAt');
const url = await mutateAsync({ username, slug }); const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
download(url); download(url);
}; };

View File

@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material';
import { ButtonBase } from '@mui/material'; import { ButtonBase } from '@mui/material';
import { Resume } from '@reactive-resume/schema'; import { Resume } from '@reactive-resume/schema';
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs';
import download from 'downloadjs'; import download from 'downloadjs';
import get from 'lodash/get'; import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
@ -96,7 +97,9 @@ const Preview: NextPage<Props> = ({ username, slug, resume: initialData }) => {
const handleDownload = async () => { const handleDownload = async () => {
try { try {
const url = await mutateAsync({ username, slug }); const updatedAt = get(resume, 'updatedAt');
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
download(url); download(url);
} catch { } catch {

View File

@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material';
import { ButtonBase } from '@mui/material'; import { ButtonBase } from '@mui/material';
import { Resume } from '@reactive-resume/schema'; import { Resume } from '@reactive-resume/schema';
import clsx from 'clsx'; import clsx from 'clsx';
import dayjs from 'dayjs';
import download from 'downloadjs'; import download from 'downloadjs';
import get from 'lodash/get'; import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
@ -69,7 +70,7 @@ const Preview: NextPage<Props> = ({ shortId }) => {
const handleDownload = async () => { const handleDownload = async () => {
try { try {
const url = await mutateAsync({ username: resume.user.username, slug: resume.slug }); const url = await mutateAsync({ username: resume.user.username, slug: resume.slug, lastUpdated: dayjs(resume.updatedAt).unix().toString() });
download(url); download(url);
} catch { } catch {

View File

@ -3,7 +3,9 @@ import axios from './axios';
export type PrintResumeAsPdfParams = { export type PrintResumeAsPdfParams = {
username: string; username: string;
slug: string; slug: string;
lastUpdated: string;
}; };
export const printResumeAsPdf = (printResumeAsPdfParams: PrintResumeAsPdfParams): Promise<string> => export const printResumeAsPdf = (printResumeAsPdfParams: PrintResumeAsPdfParams): Promise<string> =>
axios.get(`/printer/${printResumeAsPdfParams.username}/${printResumeAsPdfParams.slug}`).then((res) => res.data); axios.get(`/printer/${printResumeAsPdfParams.username}/${printResumeAsPdfParams.slug}?lastUpdated=${printResumeAsPdfParams.lastUpdated}`)
.then((res) => res.data);

View File

@ -34,7 +34,7 @@ const store = configureStore({
}, },
}); });
sagaMiddleware.run(syncSaga); sagaMiddleware.run(() => syncSaga(store.dispatch));
export const persistor = persistStore(store); export const persistor = persistStore(store);

View File

@ -3,7 +3,7 @@ import debounce from 'lodash/debounce';
import { select, takeLatest } from 'redux-saga/effects'; import { select, takeLatest } from 'redux-saga/effects';
import { updateResume } from '@/services/resume'; import { updateResume } from '@/services/resume';
import { RootState } from '@/store/index'; import { AppDispatch, RootState } from '@/store/index';
import { import {
addItem, addItem,
@ -12,23 +12,24 @@ import {
deleteSection, deleteSection,
duplicateItem, duplicateItem,
editItem, editItem,
setResume,
setResumeState, setResumeState,
} from '../resume/resumeSlice'; } from '../resume/resumeSlice';
const DEBOUNCE_WAIT = 1000; const DEBOUNCE_WAIT = 1000;
const debouncedSync = debounce((resume: Resume) => updateResume(resume), DEBOUNCE_WAIT); const debouncedSync = debounce((resume: Resume, dispatch: AppDispatch) => updateResume(resume).then((resume) => dispatch(setResume(resume))), DEBOUNCE_WAIT);
function* handleSync() { function* handleSync(dispatch: AppDispatch) {
const resume: Resume = yield select((state: RootState) => state.resume.present); const resume: Resume = yield select((state: RootState) => state.resume.present);
debouncedSync(resume); debouncedSync(resume, dispatch);
} }
function* syncSaga() { function* syncSaga(dispatch: AppDispatch) {
yield takeLatest( yield takeLatest(
[setResumeState, addItem, editItem, duplicateItem, deleteItem, addSection, deleteSection], [setResumeState, addItem, editItem, duplicateItem, deleteItem, addSection, deleteSection],
handleSync () => handleSync(dispatch)
); );
} }

View File

@ -1,4 +1,4 @@
import { Controller, Get, Param } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { PrinterService } from './printer.service'; import { PrinterService } from './printer.service';
@ -7,7 +7,7 @@ export class PrinterController {
constructor(private readonly printerService: PrinterService) {} constructor(private readonly printerService: PrinterService) {}
@Get('/:username/:slug') @Get('/:username/:slug')
printAsPdf(@Param('username') username: string, @Param('slug') slug: string): Promise<string> { printAsPdf(@Param('username') username: string, @Param('slug') slug: string, @Query('lastUpdated') lastUpdated: string): Promise<string> {
return this.printerService.printAsPdf(username, slug); return this.printerService.printAsPdf(username, slug, lastUpdated);
} }
} }

View File

@ -1,9 +1,7 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PageConfig } from '@reactive-resume/schema'; import { PageConfig } from '@reactive-resume/schema';
import { mkdir, unlink, writeFile } from 'fs/promises'; import { access, mkdir, readdir, unlink, writeFile } from 'fs/promises';
import { nanoid } from 'nanoid';
import { join } from 'path'; import { join } from 'path';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { Browser, chromium } from 'playwright-chromium'; import { Browser, chromium } from 'playwright-chromium';
@ -14,7 +12,7 @@ export const DELETION_TIME = 10 * 1000; // 10 seconds
export class PrinterService implements OnModuleInit, OnModuleDestroy { export class PrinterService implements OnModuleInit, OnModuleDestroy {
private browser: Browser; private browser: Browser;
constructor(private readonly schedulerRegistry: SchedulerRegistry, private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
async onModuleInit() { async onModuleInit() {
this.browser = await chromium.launch({ this.browser = await chromium.launch({
@ -26,68 +24,70 @@ export class PrinterService implements OnModuleInit, OnModuleDestroy {
await this.browser.close(); await this.browser.close();
} }
async printAsPdf(username: string, slug: string): Promise<string> { async printAsPdf(username: string, slug: string, lastUpdated: string): Promise<string> {
const url = this.configService.get<string>('app.url');
const serverUrl = this.configService.get<string>('app.serverUrl'); const serverUrl = this.configService.get<string>('app.serverUrl');
const secretKey = this.configService.get<string>('app.secretKey');
const page = await this.browser.newPage();
await page.goto(`${url}/${username}/${slug}/printer?secretKey=${secretKey}`);
await page.waitForSelector('html.wf-active');
const pageFormat: PageConfig['format'] = await page.$$eval(
'[data-page]',
(pages) => pages[0].getAttribute('data-format') as PageConfig['format']
);
const resumePages = await page.$$eval('[data-page]', (pages) =>
pages.map((page, index) => ({
pageNumber: index + 1,
innerHTML: page.innerHTML,
height: page.clientHeight,
}))
);
const pdf = await PDFDocument.create();
const directory = join(__dirname, '..', 'assets/exports'); const directory = join(__dirname, '..', 'assets/exports');
const filename = `RxResume_PDFExport_${nanoid()}.pdf`; const filename = `RxResume_PDFExport_${username}_${slug}_${lastUpdated}.pdf`;
const publicUrl = `${serverUrl}/assets/exports/${filename}`; const publicUrl = `${serverUrl}/assets/exports/${filename}`;
for (let index = 0; index < resumePages.length; index++) { try { // check if file already exists
await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]); await access(join(directory, filename));
} catch { // create file as it doesn't exist
const buffer = await page.pdf({ // delete old files
printBackground: true, await readdir(directory).then(async (files) => {
height: resumePages[index].height, await Promise.all(files.map(async (file) => {
width: pageFormat === 'A4' ? '210mm' : '216mm', if (file.startsWith(`RxResume_PDFExport_${username}_${slug}`)) {
await unlink(join(directory, file));
}
}));
}); });
const pageDoc = await PDFDocument.load(buffer); const url = this.configService.get<string>('app.url');
const copiedPages = await pdf.copyPages(pageDoc, [0]); const secretKey = this.configService.get<string>('app.secretKey');
copiedPages.forEach((copiedPage) => pdf.addPage(copiedPage)); const page = await this.browser.newPage();
}
await page.close(); await page.goto(`${url}/${username}/${slug}/printer?secretKey=${secretKey}`);
await page.waitForSelector('html.wf-active');
const pdfBytes = await pdf.save(); const pageFormat: PageConfig['format'] = await page.$$eval(
'[data-page]',
(pages) => pages[0].getAttribute('data-format') as PageConfig['format']
);
await mkdir(directory, { recursive: true }); const resumePages = await page.$$eval('[data-page]', (pages) =>
await writeFile(join(directory, filename), pdfBytes); pages.map((page, index) => ({
pageNumber: index + 1,
innerHTML: page.innerHTML,
height: page.clientHeight,
}))
);
// Delete PDF artifacts after DELETION_TIME ms const pdf = await PDFDocument.create();
const timeout = setTimeout(async () => {
try {
await unlink(join(directory, filename));
this.schedulerRegistry.deleteTimeout(`delete-${filename}`); for (let index = 0; index < resumePages.length; index++) {
} catch { await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]);
// pass through
const buffer = await page.pdf({
printBackground: true,
height: resumePages[index].height,
width: pageFormat === 'A4' ? '210mm' : '216mm',
});
const pageDoc = await PDFDocument.load(buffer);
const copiedPages = await pdf.copyPages(pageDoc, [0]);
copiedPages.forEach((copiedPage) => pdf.addPage(copiedPage));
} }
}, DELETION_TIME);
this.schedulerRegistry.addTimeout(`delete-${filename}`, timeout); await page.close();
const pdfBytes = await pdf.save();
await mkdir(directory, { recursive: true });
await writeFile(join(directory, filename), pdfBytes);
}
return publicUrl; return publicUrl;
} }