Skip to content

The PDF service

backend-pdf-service is the small Node microservice that turns a unit’s payment plan and a base template PDF into a finished sales-offer PDF. It exists so the Laravel backend never has to run a headless browser itself. This page is the repo deep-dive; for the end-to-end orchestration (queued job, status polling, signed download URL) see the flow page: Generating a sales-offer PDF.

A stateless Node 20 + TypeScript + Express service whose one job is rendering sales-offer PDFs. Under the hood it uses Puppeteer (headless Chromium) to screenshot three HTML “blocks” to PNGs, then pdf-lib to stitch those images plus optional pages (floor plan, master plan, gallery) into a base template PDF that the caller supplies as base64.

Key dependencies (backend-pdf-service/package.json:17-23):

Package Version Role
express ^4.21.0 HTTP server / router
puppeteer ^23.11.1 Headless Chromium — renders HTML blocks to PNG
pdf-lib ^1.17.1 Merges captured images into the template PDF
zod ^4.3.6 Request-body validation
cors ^2.8.5 CORS (currently wide open — see Gotchas)
dotenv ^17.3.1 Loads .env

The service is deliberately thin and stateless — no database, no persistent disk, no PDF caching. Its own CLAUDE.md §2 makes this a hard rule: “Don’t cache PDFs by hash here — that’s the consumer’s job” (backend-pdf-service/CLAUDE.md:74). The caching, the per-user identity, and the TTL all live on the backend side.

The service exposes exactly two routes. src/index.ts mounts the sales-offer router at /api/sales-offer and adds a health probe (backend-pdf-service/src/index.ts:20-24).

POST /api/sales-offer — the render endpoint. The router is mounted at /api/sales-offer and the handler is router.post('/', ...), so the full path is POST /api/sales-offer (src/routes/sales-offer.ts:53, mounted at src/index.ts:20).

  • Auth. isAuthenticated(req) reads process.env.API_TOKEN and does a plain Bearer <token> === API_TOKEN compare (src/routes/sales-offer.ts:8-16). Missing/invalid token returns 401.
  • Request body (JSON, validated by Zod SalesOfferBodySchema, src/routes/sales-offer.schema.ts:110-114): three required keys —
    • unit — the unit object (project, name, and optional media like masterplan_image, masterplans[], gallery[]).
    • selectedPaymentTerms — the resolved payment plan (currency, price, installment breakdown).
    • salesOfferTemplate — the base PDF and section toggles: file (base64 PDF, transformed to a Uint8Array), is_floorplan_enabled, is_masterplan_enabled, is_gallery_enabled, and the optional EGP-capping fields show_capping / usd_to_egp_rate (src/routes/sales-offer.schema.ts:98-108).
  • Success response. A binary PDF: Content-Type: application/pdf and Content-Disposition: attachment; filename="Unit <name> sales-offer.pdf" (src/routes/sales-offer.ts:41-43). There is no JSON envelope on success — the body is the PDF bytes.
  • Errors. 400 with a Zod error string on a bad body (src/routes/sales-offer.ts:26-30); 401 on auth failure; 500 { error, message } if capture or assembly throws (src/routes/sales-offer.ts:45-50).

GET /health — liveness probe, returns { status: 'ok' } (src/index.ts:22-24). Used by the container/orchestrator to know the process is up.

The service itself is a plain synchronous request/response — you POST a payload, you get PDF bytes back after a few seconds. The async generate → poll → download experience lives entirely on the backend; this service is one blocking HTTP call inside a queued Laravel job. See the flow page for that machinery.

The actual outbound call is CallPdfServiceSalesOffer::handle() (backend/app/Actions/Unit/SalesOffer/CallPdfServiceSalesOffer.php:23-60):

  • URL. rtrim(config('services.pdf-service.url'), '/') . '/api/sales-offer' — the base is PDF_SERVICE_URL (backend/config/services.php:69).
  • Auth. ->withToken(config('services.pdf-service.token')) sends Authorization: Bearer <PDF_SERVICE_TOKEN> (config/services.php:70).
  • Timeout. Http::timeout(config('services.pdf-service.timeout'))PDF_SERVICE_TIMEOUT, default 120 seconds (config/services.php:71).
  • Body. ->asJson()->post($url, $payload) where $payload is { unit, selectedPaymentTerms, salesOfferTemplate } plus a generationId for log correlation (CallPdfServiceSalesOffer.php:28,43-46).
  • Logging. Metadata only — URL, unit_id, generation_id, payment_plan_id, template_size_bytes, the section flags, then status + duration_ms on the response (CallPdfServiceSalesOffer.php:30-55). Never the PDF body or customer PII.

Config / env quick reference (names and defaults only — never commit real values):

Var Where Default Note
PDF_SERVICE_URL backend config/services.php:69 http://localhost:3001 Doc drift: backend CLAUDE.md and this repo’s README say the Docker hostname http://pdf-service:3001.
PDF_SERVICE_TOKEN backend config/services.php:70 Sent as Bearer; must equal the service’s API_TOKEN.
PDF_SERVICE_TIMEOUT backend config/services.php:71 120 (s) Outbound HTTP timeout.
PDF_SERVICE_GENERATE_RATE_LIMIT_PER_MINUTE backend config/services.php:72 5 Per-user/IP throttle on the backend generate route.
API_TOKEN pdf-service .env / src/routes/sales-offer.ts:9 Unset ⇒ 401 for every request (fail closed).
PORT pdf-service src/index.ts:14 3001 Listen port.
BODY_PAYLOAD_LIMIT pdf-service src/index.ts:15 20mb Ceiling for the base64 template body.

Node 20 (the Dockerfile pins node:20-slim, backend-pdf-service/Dockerfile:2,19). Standard scripts from package.json:5-11:

Terminal window
cd backend-pdf-service
npm install
npm run dev # ts-node-dev, watches src/, hot-restarts
npm run build # tsc -> dist/ (also copies src/assets -> dist/assets)
npm start # node -r dotenv/config dist/index.js (production build)

Set API_TOKEN in .env first (copy .env.example) or every request returns 401. The server then listens on http://localhost:3001 (or PORT). Verify it’s up:

Terminal window
curl http://localhost:3001/health
# {"status":"ok"}

There are two quick manual-test scripts (no formal test framework): npm run test:html-blocks renders the three HTML blocks to test-output/ PNGs (no server, no template needed), and npm run test:sales-offer-base64 runs the full end-to-end render against a running server using the placeholder template at src/assets/sales-offer-template.pdf (package.json:10-11, README.md:136-162).

Docker (from backend-pdf-service/docker, README.md:32-40):

Terminal window
docker network create orascom # once, if it doesn't exist
cd backend-pdf-service/docker
docker compose up -d --build
docker logs -f orascom-pdf-service

Inside Docker the backend reaches it at http://pdf-service:3001. In the deployed environments there is no Docker at all — GitLab CI just restarts a systemd unit pull-pdf-service.service on staging / uat / main (backend-pdf-service/.gitlab-ci.yml:9-40).

  • Sandbox / shared-memory flags. The browser launches headless with --no-sandbox, --disable-setuid-sandbox, --disable-dev-shm-usage, --disable-gpu (src/pdf/capture-render.ts:97-106). --disable-dev-shm-usage matters: containers give /dev/shm a tiny default, and without it Chromium can crash or hang under memory pressure.
  • Leaked Chromium = the classic outage. CLAUDE.md §2 rule 2 calls this out: pages and the browser are closed in try/finally (src/pdf/capture-render.ts:47-49,107-111). A code change that drops a finally leaks Chromium processes and eventually OOM-kills the pod. Keep the finally blocks.
  • Timeouts, plural. The backend caps the whole call at PDF_SERVICE_TIMEOUT (default 120s). Inside the service each block’s page.setContent(...) waits for networkidle0 with a 10000ms timeout, then document.fonts.ready plus a fixed 300ms settle before screenshotting (src/pdf/capture-render.ts:31-36). Generation is in the seconds range — CLAUDE.md §6 warns never to call this from a tight, user-facing timeout path, which is exactly why the backend wraps it in a queued job.
  • Body-size ceiling. express.json({ limit: BODY_PAYLOAD_LIMIT ?? '20mb' }) (src/index.ts:15,18). The base64 template PDF plus any inline data must fit under this; a big template can exceed 20mb, and raising it eats heap on JSON parse — bump it deliberately.
  • Stateless by contract. No caching here — if you’re chasing a “stale PDF” or “why is this regenerating” question, the cache lives on the backend (sales-offers/cache/{sha}.pdf), not in this service.
  • SSRF surface (image fetch). embedImageWithFallback does an unrestricted fetch(imageUrl) on caller-supplied URLs — no scheme/host allowlist, no size cap (src/pdf/embed-image.ts:6-33), reachable via unit.masterplan_image, unit.masterplans[].image, unit.gallery[]. A fetch error is logged and swallowed to null, so a broken image is a silent gap in the PDF, not a failure. Treat the service as needing an egress allowlist and keep the network locked to the backend.
  • Docs drift. Both README.md and CLAUDE.md still describe the old “no API_TOKEN ⇒ open” auth; the code fails closed. When the code and these docs disagree, the code wins.

For how a stuck or failed generation is diagnosed end-to-end (status machine, logs, the download signature), see Generating a sales-offer PDF.