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.
OpenTelemetry provides distributed tracing, logging, and metrics instrumentation for the factory service. This helps you monitor performance, debug issues, and understand request flows across your system.
The simplest setup requires just one environment variable:
# .env
OTEL_EXPORTER_OTLP_ENDPOINT="http://lgtm:4318/v1/traces"
OTEL_SERVICE_NAME="proposal-factory"Then start the service:
npm run devIf successful, you'll see this in the console:
Starting OpenTelemetry tracing with configuration: {
tracesEndpoint: 'http://lgtm:4318/v1/traces',
service: 'proposal-factory'
}
| Variable | Description | Example |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP HTTP endpoint for traces | http://lgtm:4318/v1/traces |
OTEL_SERVICE_NAME |
Service identifier in traces | proposal-factory |
| Variable | Description | Example |
|---|---|---|
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT |
OTLP HTTP endpoint for logs | http://lgtm:4318/v1/logs |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT |
OTLP HTTP endpoint for metrics | http://lgtm:4318/v1/metrics |
Full observability (tracing + logs + metrics):
OTEL_EXPORTER_OTLP_ENDPOINT="http://lgtm:4318/v1/traces"
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="http://lgtm:4318/v1/logs"
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://lgtm:4318/v1/metrics"
OTEL_SERVICE_NAME="proposal-factory"
DEPENDENCY_CONFIG= "stfc"Docker Compose with LGTM/Grafana:
# docker-compose.yml
services:
factory:
image: user-office-factory:latest
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: "http://lgtm:4318/v1/traces"
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "http://lgtm:4318/v1/logs"
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "http://lgtm:4318/v1/metrics"
DEPENDENCY_CONFIG: stfc
OTEL_SERVICE_NAME: "proposal-factory"
ports:
- "4500:4500"
lgtm:
image: grafana/otel-lgtm:latest
ports:
- "3000:3000" # Grafana
- "4318:4318" # OTLP HTTP receiver✅ Service started successfully:
Starting OpenTelemetry tracing with configuration: {
tracesEndpoint: 'http://lgtm:4318/v1/traces',
logsEndpoint: 'http://lgtm:4318/v1/logs',
service: 'proposal-factory'
}
Goto "grafana explorer"