diff --git a/.env.example b/.env.example index 1ec281313..f22604344 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,9 @@ APP_URL="http://localhost:3000" # Optional, uses APP_URL by default # This can be set to a different URL (like http://host.docker.internal:3000 or http://{docker_service}:3000) # to let the browser navigate to a non-public instance of Reactive Resume +# Note: set this to `http://host.docker.internal:3000` if your printer service is running on docker, but Reactive Resume is running outside of Docker. PRINTER_APP_URL="http://host.docker.internal:3000" -# Note: set this to `http://host.docker.internal:3000` if your `gotenberg` service is running on docker, but Reactive Resume is running outside of Docker. - # --- Printer --- PRINTER_ENDPOINT="ws://localhost:4000?token=1234567890" diff --git a/compose.dev.yml b/compose.dev.yml index 4e6ac5bd1..04c1af3c7 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -27,12 +27,24 @@ services: extra_hosts: - "host.docker.internal:host-gateway" environment: + - QUEUED=10 - HEALTH=true - - MAX_CPU_PERCENT=90 - - MAX_MEMORY_PERCENT=90 - - QUEUED=5 - - CONCURRENT=5 + - CONCURRENT=20 - TOKEN=1234567890 + healthcheck: + test: + ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"] + interval: 10s + timeout: 5s + retries: 10 + + # As an alternative to browserless, you can also use a lightweight image like chromedp/headless-shell:latest + # See https://docs.rxresu.me/self-hosting/docker#alternative-printer-options for more information. + # chrome: + # image: chromedp/headless-shell:latest + # restart: unless-stopped + # ports: + # - "9222:9222" seaweedfs: image: chrislusf/seaweedfs:latest diff --git a/compose.yml b/compose.yml index 4bf2306fb..03cae4a7f 100644 --- a/compose.yml +++ b/compose.yml @@ -17,22 +17,32 @@ services: timeout: 10s retries: 3 - gotenberg: - image: gotenberg/gotenberg:8 + browserless: + image: ghcr.io/browserless/chromium:latest restart: unless-stopped + ports: + - "4000:3000" extra_hosts: - "host.docker.internal:host-gateway" environment: - - WEBHOOK_DISABLE=true - - CHROMIUM_AUTO_START=true - - LIBREOFFICE_DISABLE_ROUTES=true - - PROMETHEUS_DISABLE_COLLECT=true + - QUEUED=10 + - HEALTH=true + - CONCURRENT=20 + - TOKEN=1234567890 healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] - start_period: 10s - interval: 30s - timeout: 10s - retries: 3 + test: + ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"] + interval: 10s + timeout: 5s + retries: 10 + + # As an alternative to browserless, you can also use a lightweight image like chromedp/headless-shell:latest + # See https://docs.rxresu.me/self-hosting/docker#alternative-printer-options for more information. + # chrome: + # image: chromedp/headless-shell:latest + # restart: unless-stopped + # ports: + # - "9222:9222" seaweedfs: image: chrislusf/seaweedfs:latest @@ -74,7 +84,8 @@ services: - APP_URL=http://localhost:3000 - PRINTER_APP_URL=http://app:3000 # Printer - - GOTENBERG_ENDPOINT=http://gotenberg:3000 + - PRINTER_ENDPOINT=ws://browserless:3000?token=1234567890 + # - PRINTER_ENDPOINT=http://chrome:9222 # Or, if you're using chromedp/headless-shell # Database - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres # Authentication @@ -97,16 +108,7 @@ services: seaweedfs-create-bucket: condition: service_completed_successfully healthcheck: - test: - [ - "CMD", - "curl", - "-f", - "http://localhost:3000/api/health", - "||", - "exit", - "1", - ] + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] start_period: 10s interval: 30s timeout: 10s diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index b0afdc35d..3270938f5 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -166,7 +166,6 @@ Here's a complete list of environment variables you can configure: | Variable | Description | Default | |----------|-------------|---------| | `PRINTER_APP_URL` | Public URL for the printer to access the Application | — | -| `PRINTER_TOKEN` | Printer authentication token (if using browserless with token auth) | — | | `GOOGLE_CLIENT_ID` | Google OAuth Client ID | — | | `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — | | `GITHUB_CLIENT_ID` | GitHub OAuth Client ID | — | diff --git a/docs/self-hosting/docker.mdx b/docs/self-hosting/docker.mdx index 3ab6d8cf7..4ac5bb4c8 100644 --- a/docs/self-hosting/docker.mdx +++ b/docs/self-hosting/docker.mdx @@ -64,11 +64,9 @@ APP_URL="http://localhost:3000" PRINTER_APP_URL="http://host.docker.internal:3000" # --- Printer --- -PRINTER_ENDPOINT="http://printer:3000" - -# Printer Authentication (Optional) -# If using browserless with a token, set PRINTER_TOKEN -# PRINTER_TOKEN="" +# If using browserless with token authentication, include the token as a query parameter: +# PRINTER_ENDPOINT="ws://printer:3000?token=your-secret-token" +PRINTER_ENDPOINT="ws://printer:3000" # --- Database (PostgreSQL) --- DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres" @@ -179,9 +177,9 @@ services: extra_hosts: - "host.docker.internal:host-gateway" environment: - - TIMEOUT=120000 - - CONCURRENT=10 - HEALTH=true + - CONCURRENT=20 + - QUEUED=10 # Optional: Set a token for authentication # - TOKEN=your-secret-token healthcheck: @@ -219,7 +217,17 @@ volumes: - **Alternative Printer Options**: If you don't want to use browserless, you can also use any headless Chrome/Chromium instance with its remote debugging port open. For example, you could run `chromium --remote-debugging-port=9222` and point `PRINTER_ENDPOINT` to that instance. + **Alternative Printer Options**: If you don't want to use browserless, you can also use a lightweight headless Chrome Docker image like `chromedp/headless-shell`: + + ```yaml + chrome: + image: chromedp/headless-shell:latest + restart: unless-stopped + ports: + - "9222:9222" + ``` + + Then set `PRINTER_ENDPOINT` to `http://chrome:9222` (or `http://localhost:9222` if running outside Docker Compose). This provides the same PDF/screenshot generation functionality with a smaller image footprint. @@ -283,11 +291,20 @@ docker compose logs -f reactive-resume - - **`PRINTER_ENDPOINT`**: Base URL where Reactive Resume reaches the printer service. In Compose: `http://printer:3000`. - - **`PRINTER_TOKEN`** (optional): Authentication token for the printer service. Required if your browserless instance is configured with token authentication. + - **`PRINTER_ENDPOINT`**: Base URL where Reactive Resume reaches the printer service. In Compose: `http://printer:3000`. If using browserless with token authentication, include the token as a query parameter: `ws://printer:3000?token=your-secret-token`. - **Alternative to browserless**: You can use any headless Chrome/Chromium instance with its remote debugging port open. For example, run `chromium --remote-debugging-port=9222` and set `PRINTER_ENDPOINT` to point to that instance. + **Alternative to browserless**: You can use a lightweight headless Chrome Docker image like `chromedp/headless-shell`: + + ```yaml + chrome: + image: chromedp/headless-shell:latest + restart: unless-stopped + ports: + - "9222:9222" + ``` + + Set `PRINTER_ENDPOINT` to `http://chrome:9222` (in Docker Compose) or `http://localhost:9222` (if running externally). This provides the same PDF/screenshot generation with a smaller image footprint. diff --git a/docs/self-hosting/examples.mdx b/docs/self-hosting/examples.mdx index d5efecf2c..920c1163b 100644 --- a/docs/self-hosting/examples.mdx +++ b/docs/self-hosting/examples.mdx @@ -356,7 +356,7 @@ services: - CONCURRENT=10 - HEALTH=true # Optional: Set a token for authentication - # - TOKEN=$PRINTER_TOKEN + # - TOKEN=your-secret-token healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/"] interval: 30s @@ -437,9 +437,9 @@ services: - reactive_resume_app_data:/app/data environment: - APP_URL=$APP_URL + # If using browserless with token auth, include the token in the URL: + # PRINTER_ENDPOINT=ws://printer:3000?token=your-secret-token - PRINTER_ENDPOINT=$PRINTER_ENDPOINT - # Optional: Set PRINTER_TOKEN if using browserless with token authentication - # - PRINTER_TOKEN=$PRINTER_TOKEN - DATABASE_URL=$DATABASE_URL - AUTH_SECRET=$AUTH_SECRET - GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID diff --git a/src/integrations/orpc/services/printer.ts b/src/integrations/orpc/services/printer.ts index 57f07bae0..d6f9483b8 100644 --- a/src/integrations/orpc/services/printer.ts +++ b/src/integrations/orpc/services/printer.ts @@ -1,25 +1,11 @@ import type { InferSelectModel } from "drizzle-orm"; -import puppeteer, { type Browser, type Page } from "puppeteer-core"; +import puppeteer, { type Browser, type ConnectOptions, type Page } from "puppeteer-core"; import type { schema } from "@/integrations/drizzle"; import { printMarginTemplates } from "@/schema/templates"; import { env } from "@/utils/env"; import { generatePrinterToken } from "@/utils/printer-token"; import { getStorageService, uploadFile } from "./storage"; -type PressureResponse = { - cpu: number; - date: number; - isAvailable: boolean; - maxConcurrent: number; - maxQueued: number; - memory: number; - message: string; - queued: number; - reason: string; - recentlyRejected: number; - running: number; -}; - const pageDimensions = { a4: { width: "210mm", @@ -36,6 +22,26 @@ const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour let pdfBrowser: Browser | null = null; let screenshotBrowser: Browser | null = null; +async function getBrowser(type: "pdf" | "screenshot"): Promise { + const endpoint = new URL(env.PRINTER_ENDPOINT); + const isWebSocket = endpoint.protocol.startsWith("ws"); + + const connectOptions: ConnectOptions = { acceptInsecureCerts: true }; + + if (isWebSocket) connectOptions.browserWSEndpoint = env.PRINTER_ENDPOINT; + else connectOptions.browserURL = env.PRINTER_ENDPOINT; + + if (type === "screenshot") { + if (screenshotBrowser?.connected) return screenshotBrowser; + screenshotBrowser = await puppeteer.connect({ ...connectOptions, defaultViewport: { width: 794, height: 1123 } }); + return screenshotBrowser; + } + + if (pdfBrowser?.connected) return pdfBrowser; + pdfBrowser = await puppeteer.connect(connectOptions); + return pdfBrowser; +} + async function interceptLocalhostRequests(page: Page) { await page.setRequestInterception(true); @@ -53,18 +59,29 @@ async function interceptLocalhostRequests(page: Page) { } export const printerService = { - healthcheck: async (): Promise => { - const printerEndpoint = env.PRINTER_ENDPOINT; - + healthcheck: async (): Promise => { const headers = new Headers({ Accept: "application/json" }); - const endpoint = new URL(printerEndpoint); - endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : "http:"; - endpoint.pathname = "/pressure"; + const endpoint = new URL(env.PRINTER_ENDPOINT); + + endpoint.protocol = endpoint.protocol.replace("ws", "http"); + endpoint.pathname = "/json/version"; const response = await fetch(endpoint, { headers }); - const data = (await response.json()) as { pressure: PressureResponse }; + const data = await response.json(); - return data.pressure; + return data; + }, + + chromeDebug: async (): Promise => { + const browser = await getBrowser("pdf"); + + const page = await browser.newPage(); + + await page.goto("https://www.google.com"); + + await page.pdf({ path: `screenshot-${Date.now()}.pdf` }); + + await browser.disconnect(); }, printResumeAsPDF: async ( @@ -95,16 +112,11 @@ export const printerService = { marginY = Math.round(data.metadata.page.marginY / 0.75); } - if (!pdfBrowser || !pdfBrowser.connected) { - pdfBrowser = await puppeteer.connect({ - acceptInsecureCerts: true, - browserWSEndpoint: env.PRINTER_ENDPOINT, - }); - } + const browser = await getBrowser("pdf"); - await pdfBrowser.setCookie({ name: "locale", value: locale, domain }); + await browser.setCookie({ name: "locale", value: locale, domain }); - const page = await pdfBrowser.newPage(); + const page = await browser.newPage(); if (env.APP_URL.includes("localhost")) await interceptLocalhostRequests(page); @@ -176,17 +188,11 @@ export const printerService = { const token = generatePrinterToken(id); const url = `${baseUrl}/printer/${id}?token=${token}`; - if (!screenshotBrowser || !screenshotBrowser.connected) { - screenshotBrowser = await puppeteer.connect({ - acceptInsecureCerts: true, - defaultViewport: { width: 794, height: 1123 }, - browserWSEndpoint: env.PRINTER_ENDPOINT, - }); - } + const browser = await getBrowser("screenshot"); - await screenshotBrowser.setCookie({ name: "locale", value: locale, domain }); + await browser.setCookie({ name: "locale", value: locale, domain }); - const page = await screenshotBrowser.newPage(); + const page = await browser.newPage(); if (env.APP_URL.includes("localhost")) await interceptLocalhostRequests(page); diff --git a/src/routes/api/health.ts b/src/routes/api/health.ts index a22370cc5..45356dbeb 100644 --- a/src/routes/api/health.ts +++ b/src/routes/api/health.ts @@ -68,8 +68,7 @@ async function checkPrinter() { async function checkStorage() { try { const storageService = getStorageService(); - const result = await storageService.healthcheck(); - return result; + return await storageService.healthcheck(); } catch (error) { return { status: "unhealthy", diff --git a/src/utils/env.ts b/src/utils/env.ts index 9b233ab4c..be91a145d 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -15,7 +15,7 @@ export const env = createEnv({ PRINTER_APP_URL: z.url({ protocol: /https?/ }).optional(), // Printer - PRINTER_ENDPOINT: z.url({ protocol: /wss?/ }), + PRINTER_ENDPOINT: z.url({ protocol: /^(wss?|https?)$/ }), // Database DATABASE_URL: z.url({ protocol: /postgres(ql)?/ }),