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.
What it is
Section titled “What it is”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 endpoint(s)
Section titled “The endpoint(s)”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)readsprocess.env.API_TOKENand does a plainBearer <token> === API_TOKENcompare (src/routes/sales-offer.ts:8-16). Missing/invalid token returns401. - 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 likemasterplan_image,masterplans[],gallery[]).selectedPaymentTerms— the resolved payment plan (currency, price, installment breakdown).salesOfferTemplate— the base PDF and section toggles:file(base64 PDF, transformed to aUint8Array),is_floorplan_enabled,is_masterplan_enabled,is_gallery_enabled, and the optional EGP-capping fieldsshow_capping/usd_to_egp_rate(src/routes/sales-offer.schema.ts:98-108).
- Success response. A binary PDF:
Content-Type: application/pdfandContent-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.
400with a Zod error string on a bad body (src/routes/sales-offer.ts:26-30);401on 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.
How the backend calls it
Section titled “How the backend calls it”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 isPDF_SERVICE_URL(backend/config/services.php:69). - Auth.
->withToken(config('services.pdf-service.token'))sendsAuthorization: Bearer <PDF_SERVICE_TOKEN>(config/services.php:70). - Timeout.
Http::timeout(config('services.pdf-service.timeout'))—PDF_SERVICE_TIMEOUT, default120seconds (config/services.php:71). - Body.
->asJson()->post($url, $payload)where$payloadis{ unit, selectedPaymentTerms, salesOfferTemplate }plus agenerationIdfor log correlation (CallPdfServiceSalesOffer.php:28,43-46). - Logging. Metadata only — URL,
unit_id,generation_id,payment_plan_id,template_size_bytes, the section flags, thenstatus+duration_mson 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. |
Running locally
Section titled “Running locally”Node 20 (the Dockerfile pins node:20-slim, backend-pdf-service/Dockerfile:2,19). Standard scripts from package.json:5-11:
cd backend-pdf-servicenpm installnpm run dev # ts-node-dev, watches src/, hot-restartsnpm 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:
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):
docker network create orascom # once, if it doesn't existcd backend-pdf-service/dockerdocker compose up -d --builddocker logs -f orascom-pdf-serviceInside 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).
Gotchas
Section titled “Gotchas”- 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-usagematters: containers give/dev/shma 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 intry/finally(src/pdf/capture-render.ts:47-49,107-111). A code change that drops afinallyleaks Chromium processes and eventually OOM-kills the pod. Keep thefinallyblocks. - Timeouts, plural. The backend caps the whole call at
PDF_SERVICE_TIMEOUT(default120s). Inside the service each block’spage.setContent(...)waits fornetworkidle0with a10000mstimeout, thendocument.fonts.readyplus a fixed300mssettle 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 exceed20mb, 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).
embedImageWithFallbackdoes an unrestrictedfetch(imageUrl)on caller-supplied URLs — no scheme/host allowlist, no size cap (src/pdf/embed-image.ts:6-33), reachable viaunit.masterplan_image,unit.masterplans[].image,unit.gallery[]. A fetch error is logged and swallowed tonull, 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.mdandCLAUDE.mdstill describe the old “noAPI_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.
