A mini service to generate PDF from HTML, uses Handlebars for parsing the HTML, Puppeteer to run the headless browser, and NATS for handling queue.
- Go to
composedirectorycd compose. - Copy the
.env.exampleto.envand set the variables. - Run
docker compose up --build -d. - The swagger documentation is on path
/docs. - The main endpoint is
/pdfwith methodPOST, for the payload described below.
v2.0.0 upgrades the whole dependency stack (Fastify 5, Zod 4, Puppeteer 25, html-validate 11) and moves the image to Node 22. There is one breaking change to the API, plus two operational requirements.
When a request fails validation the service still returns 400 with the same success / data / message / details envelope. Only the objects inside details changed, because the validation errors now arrive in Fastify's shape rather than Zod's.
Before (v1.x):
{
"success": false,
"data": null,
"message": "Invalid input",
"details": [
{ "code": "invalid_type", "fatal": false, "message": "Required", "path": "html" }
]
}After (v2.0.0):
{
"success": false,
"data": null,
"message": "Invalid input",
"details": [
{
"keyword": "invalid_type",
"instancePath": "/html",
"schemaPath": "#/html/invalid_type",
"message": "Invalid input: expected string, received undefined",
"params": { "expected": "string" }
}
]
}Field mapping:
| v1.x | v2.0.0 | Note |
|---|---|---|
code |
keyword |
Same values, e.g. invalid_type, custom. |
path |
instancePath |
Now a JSON-pointer style path: html becomes /html. |
message |
message |
Unchanged in meaning; Zod 4 wording is more descriptive. |
fatal |
— | Removed. |
| — | schemaPath |
New. |
| — | params |
New; per-issue metadata, may be {}. |
HTML validation errors are unaffected in content: they still arrive as keyword: "custom" with a message starting Invalid HTML:.
Only clients that read individual fields inside details need changing. Anything checking success, message, or the HTTP status works as-is.
The image is now node:22-alpine. If you run the service outside Docker you need Node >= 22.22.0 (this is html-validate 11's floor; Puppeteer 25 requires >= 22.12.0). This is enforced via engines in package.json.
The Dockerfile sets PUPPETEER_SKIP_DOWNLOAD=true and points PUPPETEER_EXECUTABLE_PATH at the Alpine chromium package, so builds no longer pull a second ~170MB browser. The old PUPPETEER_SKIP_CHROMIUM_DOWNLOAD variable in compose/.env was renamed away by Puppeteer v20 and had no effect; it has been removed. If you run outside Docker, either install Chrome and set PUPPETEER_EXECUTABLE_PATH, or install dependencies without PUPPETEER_SKIP_DOWNLOAD so Puppeteer fetches its own browser.
DISABLE_NATS=true also exists now, but it is for the test suite only — it replaces the queue with a stub and must never be set in a deployment.
Commands to manage git tags for Docker image releases.
| Command | Usage | Description |
|---|---|---|
tag-delete |
make tag-delete TAG=v1.0.0 |
Delete tag from local and remote |
tag-push |
make tag-push TAG=v1.0.0 |
Create and push a new tag |
tag-repush |
make tag-repush TAG=v1.0.0 |
Delete existing tag and re-push (useful when a build fails) |
| Variable | Required | Default | Desc |
|---|---|---|---|
PORT |
No | 3000 |
Port the HTTP server listens on. |
QUEUE_URL |
Yes | — | NATS connection string. Carries credentials and TLS settings — see Connecting to a protected NATS. |
QUEUE_SUBJECT |
No | generate.pdf |
Subject the /pdf endpoint publishes jobs to. |
QUEUE_SUBSCRIBE |
No | generate.> |
Subject pattern the worker subscribes to. |
Everything needed to reach an authenticated NATS server goes in QUEUE_URL — there are no separate username or password variables.
nats://[user:pass@|token@]host:port[,host:port...][?tls=true&tls_ca_file=…&tls_insecure=true]
tls://… # the tls:// scheme also enables TLS
| Example | Result |
|---|---|
nats://nats:4222 |
no auth |
nats://user:pass@nats:4222 |
user/password |
nats://sometoken@nats:4222 |
token (userinfo with no password) |
tls://user:pass@nats:4222 |
user/password + TLS |
nats://u:p@nats:4222?tls=true&tls_insecure=true |
TLS, skip verification (dev only) |
tls://u:p@nats:4222?tls_ca_file=/certs/ca.pem |
TLS with a private CA |
nats://user:pass@h1:4222,h2:4222 |
cluster; credentials taken from the first entry |
nats:4222 |
no scheme — still works |
Special characters in a password must be percent-encoded, exactly as in a Postgres or Redis connection string. For example p@ss becomes p%40ss, and a,b becomes a%2Cb. An unencoded @ or , will be misread as a host separator.
TLS query parameters:
| Parameter | Desc |
|---|---|
tls |
true enables TLS using the system CA store. Implied by the tls:// scheme. |
tls_ca_file |
Path to a CA certificate, for a private/self-signed CA. |
tls_cert_file |
Path to a client certificate, for mTLS. |
tls_key_file |
Path to the client private key, for mTLS. |
tls_insecure |
true skips certificate verification. Development only. |
Notes:
- Credentials in
QUEUE_URLare parsed by this service. The underlying nats.js client ignores userinfo in a server URL on its own, so this syntax works here even though passing the same URL straight to the library would not authenticate. - NKey, JWT and
.credsfile authentication are not supported, so managed NATS (Synadia Cloud / NGS) will not work. - The connection log prints only host, port and the auth mode — a password is never written to the logs.
- This service is using NATS. Run a NATS server locally, or use the one in
compose/compose.yaml. That bundled server is unauthenticated and intended for local development; pointQUEUE_URLat your own server to use authentication. - The
clientdirectory is only the example how to interact with the service.
| Field | Required | Desc |
|---|---|---|
| html | Yes | The escaped HTML string for rendering. |
| header | No | The escaped HTML string for header purpose. |
| footer | No | The escaped HTML string for footer purpose. |
| margin | No | The object for setting the margin { top, right, bottom, left }. |
| alias | No | The alias for the filename. |
| values | No | The object for the data that needed by the html. |
| format | No | The format for generated PDF, should be one of "letter" | "legal" | "tabloid" | "ledger" | "a0" | "a1" | "a2" | "a3" | "a4" | "a5" | "a6", this field will take over the width and height. |
| width | No | Width of document. Ignored if format is filled. |
| height | No | Height of document. Ignored if format is filled. |
| webhookUrl | No | URL to receive the generated PDF. See Webhook Payload below. |
| webhookFormat | No | How the webhook body is encoded: "json" (default) or "multipart". See Webhook Payload. |
| metadata | No | Arbitrary key-value object sent along with the webhook, so the receiver can identify/route the data (e.g. { "orderId": "123", "userId": "456" }). |
When webhookUrl is provided, the service POSTs the finished PDF to that URL. Two encodings are available, selected with webhookFormat.
Content-Type: application/json
{
"alias": "my-document",
"metadata": { "orderId": "123", "userId": "456" },
"pdf": "<base64-encoded PDF>"
}| Field | Type | Description |
|---|---|---|
| alias | string | The filename alias (without .pdf extension). |
| metadata | object | The metadata object from the original request (defaults to {}). |
| string | The generated PDF file, base64-encoded. |
Set "webhookFormat": "multipart" on the request. Content-Type: multipart/form-data; boundary=…, with two parts:
| Part | Content-Type | Description |
|---|---|---|
meta |
application/json |
{ "alias": "...", "metadata": { ... } } — the same fields as above, minus the PDF. |
pdf |
application/pdf |
The PDF as raw bytes, filename <alias>.pdf. |
Base64 inflates the body by about a third, so multipart is worth using for large documents: measured on a real request, the same PDF went out as 26,023 bytes of JSON versus 19,827 as multipart — roughly 24% smaller. It also lets a receiver stream the part straight to disk instead of holding the whole document in memory.
json remains the default, so existing receivers are unaffected.
| Name | Arguments | Return |
|---|---|---|
| add | number, number |
number |
| min | number, number |
number |
| mul | number, number |
number |
| div | number, number |
number |
| gt | number, number |
boolean |
| gte | number, number |
boolean |
| lt | number, number |
boolean |
| lte | number, number |
boolean |
| eq | number, number |
boolean |
| ne | number, number |
boolean |
| or | boolean, boolean |
boolean |
| and | boolean, boolean |
boolean |
| not | boolean |
boolean |
| contains | string, string |
boolean |
| startWith | string, string |
boolean |
| endWith | string, string |
boolean |
| replace | string, string, string |
string |
| json | object |
string |
| lower | string |
string |
| upper | string |
string |
| isEmpty | any |
boolean |