Service responsible for rendering and exporting User Office content as:
- PDF documents (proposal PDFs, sample PDFs, shipment labels)
- XLSX exports (proposal and FAP/call FAP)
- ZIP archives (attachments and proposal bundles)
The service exposes a small HTTP API and uses a workflow system to generate the requested output and stream it back to the client.
- Node.js >= 22 (see
package.json#engines) - Postgres connectivity (for file/attachment data)
- Chromium (via Puppeteer)
npm install
cp example.env .env
npm run devBy default the server listens on port 4500.
POST /generate/:downloadType/:type
The response streams the generated output back to the client.
downloadType:
pdfxlsxzip
Supported type values:
pdf:proposal,sample,shipment-labelxlsx:proposal,fap,call_fapzip:attachment,proposal
PDF generation uses Puppeteer. The service supports two modes:
By default, the service launches a local Chromium instance via Puppeteer. This is the simplest setup and maintains backward compatibility.
- No additional configuration required
- Chromium is bundled with Puppeteer
- Recommended
MAX_CONCURRENT_PDF_GENERATIONS: 2 (depending on resources)
For better scalability and resource isolation, you can offload browser rendering to a remote Browserless cluster. This separates the Node.js application from the Chrome rendering workload.
Why use a remote browser instead of built-in Chromium?
- Better scalability: Browser capacity can be scaled independently from API replicas.
- Resource isolation: Chrome CPU/RAM spikes do not directly impact the Node.js process.
- Higher throughput: Multiple factory instances can share one Browserless cluster.
- Operational flexibility: Browser lifecycle, limits, and upgrades are managed in one place.
- Improved resilience: Browser crashes are isolated from the app and easier to recover from.
Environment variables for Browserless:
BROWSER_WS_ENDPOINT- WebSocket endpoint of the Browserless cluster (e.g.,ws://browserless:3000)FACTORY_BASE_URL- Base URL where the factory service is reachable by the remote browser (e.g.,http://factory:4500)
When BROWSER_WS_ENDPOINT is set, the service connects to the remote cluster instead of launching local Chromium. Each PDF generation creates a fresh browser session managed by Browserless.
Recommended MAX_CONCURRENT_PDF_GENERATIONS for Browserless: 5-10 (depending on cluster size and resources)
Use environment variables to select the browser mode.
- Do not set
BROWSER_WS_ENDPOINT. - Optionally remove
FACTORY_BASE_URL(it is not required in built-in mode). - Set
MAX_CONCURRENT_PDF_GENERATIONSconservatively (start around2). - Optional: set
UO_FEATURE_ALLOW_NO_SANDBOX=1only if your runtime requires it.
- Start/reach a Browserless service.
- Set
BROWSER_WS_ENDPOINTto the Browserless WebSocket endpoint. - Set
FACTORY_BASE_URLto the factory URL resolvable by Browserless. - Tune
MAX_CONCURRENT_PDF_GENERATIONSto Browserless capacity (for one pod, approximate upper bound isCONCURRENT + QUEUED). - Ignore
UO_FEATURE_ALLOW_NO_SANDBOX(it only affects built-in Chromium launch).
- The service uses a semaphore to limit concurrent Puppeteer page work. (see
MAX_CONCURRENT_PDF_GENERATIONS) - This protects CPU/memory under load (e.g. “download multiple proposals”).
- Navigation and operation timeouts are controlled via
PDF_GENERATION_TIMEOUT.
Copy and adjust example.env as needed.
NODE_PORT(default:4500)NODE_ENV(development/production)REQUEST_BODY_LIMIT(default:20mb) maximum accepted request body size for JSON/urlencoded payloads- Increase this when
/generaterequests include large embedded template/data payloads
- Increase this when
Either provide a full connection string:
DATABASE_CONNECTION_STRING
Or provide discrete settings:
DATABASE_HOSTNAMEDATABASE_PORT(default:5432)DATABASE_USERDATABASE_PASSWORDDATABASE_DATABASE
MAX_CONCURRENT_PDF_GENERATIONS(default:2) to limit concurrent PDF generations, adjust based on available CPU/memory.- Built-in Chromium: recommended max. 2-4
- Remote Browserless cluster: 5-10 (depending on cluster size and resources)
- When Puppeteer throws like
Protocol error: Connection closed.errors under load, reduce this value.
PDF_GENERATION_TIMEOUT(default:60000ms) to set maximum time for PDF generation- When Navigation timeout errors occur, increase this value.
PDF_MAX_RETRIES(default:3) maximum attempts for transient PDF generation failures- Retry backoff is exponential (
2s,4s,8s, ...).
- Retry backoff is exponential (
PDF_DEBUG_HTML=1to write the rendered HTML alongside the generated PDFUO_FEATURE_ALLOW_NO_SANDBOX=1to launch Chromium with--no-sandbox- Use only when your runtime cannot support Chromium sandboxing (common in some containers).
- Applies only to built-in Chromium mode (
puppeteer.launch), ignored in Browserless mode. Security note: disabling sandbox reduces browser process isolation.
See k8s/browserless/ for Kubernetes deployment instructions.
BROWSER_WS_ENDPOINT- WebSocket endpoint of the Browserless cluster- Examples:
- Docker Compose:
ws://browserless:3000 - Local dev + Browserless in Docker:
ws://localhost:3010 - Kubernetes:
ws://browserless.default.svc.cluster.local:3000
- Docker Compose:
- When set, PDF generation uses the remote browser cluster instead of local Chromium
- Examples:
FACTORY_BASE_URL- Base URL of the factory app, used by the remote browser to fetch static assets (CSS, fonts, images, JS)- Defaults to
http://localhost:<NODE_PORT>for local development - Must be set to a hostname resolvable by the Browserless container
- Examples:
- Docker Compose:
http://factory:4500 - Local dev + Browserless in Docker:
http://host.docker.internal:4500 - Kubernetes:
http://<service-name>.<namespace>.svc.cluster.local:<port>
- Docker Compose:
- Defaults to
Set MAX_CONCURRENT_PDF_GENERATIONS based on the browser mode and available capacity:
- Built-in Chromium mode: start around
2and increase only if CPU/memory headroom allows it. - Remote Browserless mode: size against Browserless capacity.
- Approximate upper bound per Browserless pod is
CONCURRENT + QUEUED. - Example:
CONCURRENT=5,QUEUED=10=> upper bound15. - If multiple Browserless pods are behind a load balancer, total cluster capacity scales by pod count.
- Approximate upper bound per Browserless pod is
Practical tuning guidance:
- If you see browser connection/queue errors, reduce
MAX_CONCURRENT_PDF_GENERATIONS. - If requests time out, increase
PDF_GENERATION_TIMEOUTand/or lower concurrency. - Increase concurrency gradually while monitoring CPU, memory, error rate, and PDF completeness.
When creating custom templates that reference factory-hosted assets (images, fonts, CSS, JS), use the {{factoryBaseUrl}} Handlebars helper instead of hardcoding URLs.
- Built-in Chromium mode: The browser runs locally and can access
localhostdirectly - Browserless mode: The remote browser cannot resolve
localhost- it needs the factory's network-reachable URL
Using {{factoryBaseUrl}} ensures templates work in both modes.
Incorrect (hardcoded localhost - breaks with Browserless):
<img src="http://localhost:4500/static/images/logo.png" />
<link rel="stylesheet" href="http://localhost:4500/static/css/custom.css" />Correct (using helper - works in all modes):
<img src="{{factoryBaseUrl}}/static/images/logo.png" />
<link rel="stylesheet" href="{{factoryBaseUrl}}/static/css/custom.css" />The {{factoryBaseUrl}} helper is automatically available in all templates and resolves to:
http://localhost:4500when using built-in Chromium (default)- The value of
FACTORY_BASE_URLenv var when using Browserless
- Improve the HTML render waiting strategy before PDF generation to ensure pages are fully rendered.
- After the waiting strategy is in place, avoid creating a new browser context for every request; evaluate reusing the default/shared context to improve cache reuse for static assets.
- Leverage static asset caching (
Cache-Control/max-age) together with context reuse to reduce repeated CSS/font/image fetches. Retry logic for transient PDF generation errors (e.g., navigation timeout error etc...)- Config class to centralize and validate environment variable parsing and defaults.