From a88a794f293e4e844382b00d1d494dc44bb354ed Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 6 Nov 2023 09:41:06 +0100 Subject: [PATCH] fix(server): :construction_worker: pass errors down to controller --- apps/server/src/printer/printer.service.ts | 388 ++++++++++---------- apps/server/src/resume/resume.controller.ts | 10 +- tools/compose/{dev.yml => development.yml} | 12 +- tools/compose/simple.yml | 8 +- tools/compose/traefik-secure.yml | 37 +- tools/compose/traefik.yml | 18 +- 6 files changed, 237 insertions(+), 236 deletions(-) rename tools/compose/{dev.yml => development.yml} (85%) diff --git a/apps/server/src/printer/printer.service.ts b/apps/server/src/printer/printer.service.ts index cbae549c..2641c4d1 100644 --- a/apps/server/src/printer/printer.service.ts +++ b/apps/server/src/printer/printer.service.ts @@ -18,7 +18,7 @@ import { UtilsService } from "../utils/utils.service"; const MM_TO_PX = 3.78; const PREVIEW_TIMEOUT = 5000; // 5 seconds -const PRINTER_TIMEOUT = 15000; // 15 seconds +const PRINTER_TIMEOUT = 10000; // 10 seconds @Injectable() export class PrinterService { @@ -89,220 +89,212 @@ export class PrinterService { async generateResume(resume: ResumeDto) { const browser = await this.getBrowser(); - try { - const page = await browser.newPage(); + const page = await browser.newPage(); - let url = this.utils.getUrl(); - const publicUrl = this.configService.getOrThrow("PUBLIC_URL"); - const storageUrl = this.configService.getOrThrow("STORAGE_URL"); + let url = this.utils.getUrl(); + const publicUrl = this.configService.getOrThrow("PUBLIC_URL"); + const storageUrl = this.configService.getOrThrow("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(); - } - }); - } - - // Set the data of the resume to be printed in the browser's session storage - const format = resume.data.metadata.page.format; - const numPages = resume.data.metadata.layout.length; - - await page.evaluateOnNewDocument((data: string) => { - sessionStorage.setItem("resume", data); - }, JSON.stringify(resume.data)); - - await page.goto(`${url}/printer`, { waitUntil: "networkidle0" }); - await page.emulateMediaType("print"); - - const pagesBuffer: Buffer[] = []; - - // Hide all the pages (elements with [data-page] attribute) using CSS - const hidePages = () => { - return page.$eval("iframe", (frame) => { - frame.contentDocument?.documentElement.querySelectorAll("[data-page]").forEach((page) => { - page.setAttribute("style", "display: none"); - }); - }); - }; - - const processPage = (index: number) => { - // Calculate the height of the page based on the format, convert mm to pixels - const pageSize = { - width: pageSizeMap[format].width * MM_TO_PX, - height: pageSizeMap[format].height * MM_TO_PX, - }; - - return page.$eval( - "iframe", - (frame, index, pageSize) => { - const page = frame.contentDocument?.querySelector(`[data-page="${index}"]`); - page?.setAttribute("style", "display: block"); - - return { - width: Math.max(pageSize.width, page?.scrollWidth ?? 0), - height: Math.max(pageSize.height, page?.scrollHeight ?? 0), - }; - }, - index, - pageSize, - ); - }; - - // 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 hidePages(); - - const { width, height } = await processPage(index); - const buffer = await page.pdf({ width, height }); - pagesBuffer.push(buffer); - - await hidePages(); - } - - // 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; - - // Handle Special Case for CMU Serif as it is not available on Google Fonts - if (fontData.family === "CMU Serif") { - const fontsBuffer = await Promise.all([ - readFile(join(__dirname, "assets/fonts/computer-modern/regular.ttf")), - readFile(join(__dirname, "assets/fonts/computer-modern/italic.ttf")), - readFile(join(__dirname, "assets/fonts/computer-modern/bold.ttf")), - ]); - - await Promise.all( - fontsBuffer.map((buffer) => { - // Convert Buffer to ArrayBuffer - const arrayBuffer = buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, - ); - return pdf.embedFont(arrayBuffer); - }), - ); - } else { - 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; - } catch (error) { - throw new InternalServerErrorException(ErrorMessage.ResumePrinterError, error); + request.continue({ url: modifiedUrl }); + } else { + request.continue(); + } + }); } + + // Set the data of the resume to be printed in the browser's session storage + const format = resume.data.metadata.page.format; + const numPages = resume.data.metadata.layout.length; + + await page.evaluateOnNewDocument((data: string) => { + sessionStorage.setItem("resume", data); + }, JSON.stringify(resume.data)); + + await page.goto(`${url}/printer`, { waitUntil: "networkidle0" }); + await page.emulateMediaType("print"); + + const pagesBuffer: Buffer[] = []; + + // Hide all the pages (elements with [data-page] attribute) using CSS + const hidePages = () => { + return page.$eval("iframe", (frame) => { + frame.contentDocument?.documentElement.querySelectorAll("[data-page]").forEach((page) => { + page.setAttribute("style", "display: none"); + }); + }); + }; + + const processPage = (index: number) => { + // Calculate the height of the page based on the format, convert mm to pixels + const pageSize = { + width: pageSizeMap[format].width * MM_TO_PX, + height: pageSizeMap[format].height * MM_TO_PX, + }; + + return page.$eval( + "iframe", + (frame, index, pageSize) => { + const page = frame.contentDocument?.querySelector(`[data-page="${index}"]`); + page?.setAttribute("style", "display: block"); + + return { + width: Math.max(pageSize.width, page?.scrollWidth ?? 0), + height: Math.max(pageSize.height, page?.scrollHeight ?? 0), + }; + }, + index, + pageSize, + ); + }; + + // 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 hidePages(); + + const { width, height } = await processPage(index); + const buffer = await page.pdf({ width, height }); + pagesBuffer.push(buffer); + + await hidePages(); + } + + // 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; + + // Handle Special Case for CMU Serif as it is not available on Google Fonts + if (fontData.family === "CMU Serif") { + const fontsBuffer = await Promise.all([ + readFile(join(__dirname, "assets/fonts/computer-modern/regular.ttf")), + readFile(join(__dirname, "assets/fonts/computer-modern/italic.ttf")), + readFile(join(__dirname, "assets/fonts/computer-modern/bold.ttf")), + ]); + + await Promise.all( + fontsBuffer.map((buffer) => { + // Convert Buffer to ArrayBuffer + const arrayBuffer = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + return pdf.embedFont(arrayBuffer); + }), + ); + } else { + 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) { const browser = await this.getBrowser(); - try { - const page = await browser.newPage(); + const page = await browser.newPage(); - let url = this.utils.getUrl(); - const publicUrl = this.configService.getOrThrow("PUBLIC_URL"); - const storageUrl = this.configService.getOrThrow("STORAGE_URL"); + let url = this.utils.getUrl(); + const publicUrl = this.configService.getOrThrow("PUBLIC_URL"); + const storageUrl = this.configService.getOrThrow("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(); - } - }); - } - - // Set the data of the resume to be printed in the browser's session storage - const format = resume.data.metadata.page.format; - - await page.evaluateOnNewDocument((data: string) => { - sessionStorage.setItem("resume", data); - }, JSON.stringify(resume.data)); - - await page.setViewport({ - width: Math.round(pageSizeMap[format].width * MM_TO_PX), - height: Math.round(pageSizeMap[format].height * MM_TO_PX), + request.continue({ url: modifiedUrl }); + } else { + request.continue(); + } }); - - await page.goto(`${url}/printer`, { waitUntil: "networkidle0" }); - - // Save the JPEG to storage and return the URL - // Store the URL in cache for future requests, under the previously generated hash digest - const buffer = await page.screenshot({ quality: 80, type: "jpeg" }); - - // Generate a hash digest of the resume data, this hash will be used to check if the resume has been updated - const previewUrl = await this.storageService.uploadObject( - resume.userId, - "previews", - buffer, - resume.id, - ); - - // Close all the pages and disconnect from the browser - await page.close(); - browser.disconnect(); - - return previewUrl; - } catch (error) { - throw new InternalServerErrorException(ErrorMessage.ResumePreviewError, error); } + + // Set the data of the resume to be printed in the browser's session storage + const format = resume.data.metadata.page.format; + + await page.evaluateOnNewDocument((data: string) => { + sessionStorage.setItem("resume", data); + }, JSON.stringify(resume.data)); + + await page.setViewport({ + width: Math.round(pageSizeMap[format].width * MM_TO_PX), + height: Math.round(pageSizeMap[format].height * MM_TO_PX), + }); + + await page.goto(`${url}/printer`, { waitUntil: "networkidle0" }); + + // Save the JPEG to storage and return the URL + // Store the URL in cache for future requests, under the previously generated hash digest + const buffer = await page.screenshot({ quality: 80, type: "jpeg" }); + + // Generate a hash digest of the resume data, this hash will be used to check if the resume has been updated + const previewUrl = await this.storageService.uploadObject( + resume.userId, + "previews", + buffer, + resume.id, + ); + + // Close all the pages and disconnect from the browser + await page.close(); + browser.disconnect(); + + return previewUrl; } } diff --git a/apps/server/src/resume/resume.controller.ts b/apps/server/src/resume/resume.controller.ts index be2b6d6c..674b1690 100644 --- a/apps/server/src/resume/resume.controller.ts +++ b/apps/server/src/resume/resume.controller.ts @@ -125,7 +125,10 @@ export class ResumeController { return { url }; } catch (error) { - throw new InternalServerErrorException(ErrorMessage.ResumePrinterError, error); + throw new InternalServerErrorException(ErrorMessage.ResumePrinterError, { + cause: error, + description: error.message, + }); } } @@ -138,7 +141,10 @@ export class ResumeController { return { url }; } catch (error) { - throw new InternalServerErrorException(ErrorMessage.ResumePreviewError); + throw new InternalServerErrorException(ErrorMessage.ResumePreviewError, { + cause: error, + description: error.message, + }); } } } diff --git a/tools/compose/dev.yml b/tools/compose/development.yml similarity index 85% rename from tools/compose/dev.yml rename to tools/compose/development.yml index c7a8103c..408b1e94 100644 --- a/tools/compose/dev.yml +++ b/tools/compose/development.yml @@ -7,7 +7,7 @@ version: "3" services: # Database (Postgres) postgres: - image: postgres + image: postgres:alpine restart: unless-stopped ports: - ${POSTGRES_PORT:-5432}:5432 @@ -18,7 +18,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} healthcheck: - test: ["CMD", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-postgres}"] interval: 10s timeout: 5s retries: 5 @@ -38,12 +38,6 @@ services: MINIO_CONSOLE_ADDRESS: :9001 MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin} MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY:-minioadmin} - healthcheck: - test: ["CMD", "curl -f http://minio:9000/minio/health/live"] - start_period: 40s - interval: 30s - timeout: 10s - retries: 3 # Chrome Browser (for printing and previews) chrome: @@ -58,7 +52,7 @@ services: # Redis (for cache & server session management) redis: - image: redis + image: redis:alpine restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD:-password} ports: diff --git a/tools/compose/simple.yml b/tools/compose/simple.yml index 839b483a..ccacdf14 100644 --- a/tools/compose/simple.yml +++ b/tools/compose/simple.yml @@ -7,7 +7,7 @@ version: "3" services: # Database (Postgres) postgres: - image: postgres + image: postgres:alpine restart: unless-stopped volumes: - postgres_data:/var/lib/postgresql/data @@ -16,7 +16,7 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres healthcheck: - test: ["CMD", "pg_isready -U postgres -d postgres"] + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 10s timeout: 5s retries: 5 @@ -45,7 +45,7 @@ services: # Redis (for cache & server session management) redis: - image: redis + image: redis:alpine restart: unless-stopped command: redis-server --requirepass password @@ -69,8 +69,8 @@ services: STORAGE_URL: http://localhost:9000 # -- Printer (Chrome) -- - CHROME_URL: ws://chrome:3000 CHROME_TOKEN: chrome_token + CHROME_URL: ws://chrome:3000 # -- Database (Postgres) -- DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres diff --git a/tools/compose/traefik-secure.yml b/tools/compose/traefik-secure.yml index eb4047e6..bd132df9 100644 --- a/tools/compose/traefik-secure.yml +++ b/tools/compose/traefik-secure.yml @@ -8,7 +8,7 @@ version: "3" services: # Database (Postgres) postgres: - image: postgres + image: postgres:alpine restart: unless-stopped volumes: - postgres_data:/var/lib/postgresql/data @@ -17,7 +17,7 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres healthcheck: - test: ["CMD", "pg_isready -U postgres -d postgres"] + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 10s timeout: 5s retries: 5 @@ -33,11 +33,11 @@ services: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin labels: - - traefik.enable - - traefik.http.routers.app.rule=Host(`storage.example.com`) - - traefik.http.routers.app.entrypoints=websecure - - traefik.http.routers.app.tls.certresolver=letsencrypt - - traefik.http.services.app.loadbalancer.server.port=9000 + - traefik.enable=true + - traefik.http.routers.storage.rule=Host(`storage.example.com`) + - traefik.http.routers.storage.entrypoints=websecure + - traefik.http.routers.storage.tls.certresolver=letsencrypt + - traefik.http.services.storage.loadbalancer.server.port=9000 # Chrome Browser (for printing and previews) chrome: @@ -47,10 +47,16 @@ services: TOKEN: chrome_token EXIT_ON_HEALTH_FAILURE: true PRE_REQUEST_HEALTH_CHECK: true + labels: + - traefik.enable=true + - traefik.http.routers.printer.rule=Host(`printer.example.com`) + - traefik.http.routers.printer.entrypoints=websecure + - traefik.http.routers.printer.tls.certresolver=letsencrypt + - traefik.http.services.printer.loadbalancer.server.port=3000 # Redis (for cache & server session management) redis: - image: redis + image: redis:alpine restart: unless-stopped command: redis-server --save 60 1 --loglevel warning --requirepass password volumes: @@ -70,12 +76,12 @@ services: NODE_ENV: production # -- URLs -- - PUBLIC_URL: http://example.com - STORAGE_URL: http://storage.example.com + PUBLIC_URL: https://example.com + STORAGE_URL: https://storage.example.com # -- Printer (Chrome) -- - CHROME_URL: ws://chrome:3000 CHROME_TOKEN: chrome_token + CHROME_URL: wss://printer.example.com # -- Database (Postgres) -- DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres @@ -111,22 +117,21 @@ services: GOOGLE_CLIENT_SECRET: google_client_secret GOOGLE_CALLBACK_URL: http://localhost:3000/api/auth/google/callback labels: - - traefik.enable + - traefik.enable=true - traefik.http.routers.app.rule=Host(`example.com`) - traefik.http.routers.app.entrypoints=websecure - - traefik.http.routers.app.tls - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 traefik: image: traefik command: - - --api - - --providers.docker + - --api=true + - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - - --certificatesresolvers.letsencrypt.acme.tlschallenge + - --certificatesresolvers.letsencrypt.acme.tlschallenge=true - --certificatesresolvers.letsencrypt.acme.email=noreply@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json diff --git a/tools/compose/traefik.yml b/tools/compose/traefik.yml index e3b58247..3ebb4f1b 100644 --- a/tools/compose/traefik.yml +++ b/tools/compose/traefik.yml @@ -8,7 +8,7 @@ version: "3" services: # Database (Postgres) postgres: - image: postgres + image: postgres:alpine restart: unless-stopped volumes: - postgres_data:/var/lib/postgresql/data @@ -17,7 +17,7 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres healthcheck: - test: ["CMD", "pg_isready -U postgres -d postgres"] + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 10s timeout: 5s retries: 5 @@ -34,8 +34,8 @@ services: MINIO_ROOT_PASSWORD: minioadmin labels: - traefik.enable=true - - traefik.http.routers.minio.rule=Host(`storage.example.com`) - - traefik.http.services.minio.loadbalancer.server.port=9000 + - traefik.http.routers.storage.rule=Host(`storage.example.com`) + - traefik.http.services.storage.loadbalancer.server.port=9000 # Chrome Browser (for printing and previews) chrome: @@ -45,10 +45,14 @@ services: TOKEN: chrome_token EXIT_ON_HEALTH_FAILURE: true PRE_REQUEST_HEALTH_CHECK: true + labels: + - traefik.enable=true + - traefik.http.routers.printer.rule=Host(`printer.example.com`) + - traefik.http.services.printer.loadbalancer.server.port=3000 # Redis (for cache & server session management) redis: - image: redis + image: redis:alpine restart: unless-stopped command: redis-server --requirepass password @@ -70,8 +74,8 @@ services: STORAGE_URL: http://storage.example.com # -- Printer (Chrome) -- - CHROME_URL: ws://chrome:3000 CHROME_TOKEN: chrome_token + CHROME_URL: ws://chrome:3000 # -- Database (Postgres) -- DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres @@ -115,7 +119,7 @@ services: image: traefik command: - --api.insecure=true - - --providers.docker + - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 ports: