safeer-pdf-generator
Version:
Framework-agnostic PDF generation library with chunking, merging, S3 upload, and email delivery
526 lines (415 loc) • 17.4 kB
Markdown
# safeer-pdf-generator
[](https://badge.fury.io/js/safeer-pdf-generator)
[](https://opensource.org/licenses/MIT)
[](https://www.npmjs.com/package/safeer-pdf-generator)
A powerful, framework-agnostic TypeScript library for generating PDF reports with chunking, merging, S3 upload, email delivery, lifecycle events, and webhook dispatch.
## ✨ Features
- 🚀 **High Performance** — Concurrent chunk processing with memory optimization
- 📊 **3 Built-in Templates** — `default`, `simple`, and `modern` with full customization
- 🎨 **Custom Templates** — Inline functions, CSS overrides, or register your own
- 🔧 **Framework Agnostic** — Works with Express, NestJS, Fastify, or standalone
- ☁️ **S3 & S3-Compatible** — Direct upload to AWS S3, Cloudflare R2, MinIO, Backblaze B2, etc.
- 📁 **Local File Output** — Write PDFs to disk alongside S3/email/webhook
- 📧 **Email Delivery** — Send PDFs as attachments or download links
- 🔀 **PDF Operations** — Merge, split, and manipulate existing PDFs
- 🌐 **BYOB Browser** — Bring Your Own Browser for connection pooling
- 📡 **Lifecycle Events** — Typed event emitters for every stage (incl. per-chunk progress)
- 🔔 **Webhook Dispatcher** — Auto-POST to your backend with HMAC signing
- 💾 **Memory Efficient** — Handle large datasets (100k+ records)
- 🛡️ **TypeScript** — Full type safety and IntelliSense support
## 📦 Installation
### Prerequisites
This package requires Chromium for PDF generation:
```bash
# Install Chromium
sudo apt-get install chromium-browser
# Or set custom path
export PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
```
### Package Installation
```bash
npm install safeer-pdf-generator
# Optional peer dependencies
npm install @aws-sdk/client-s3 nodemailer
```
## 🚀 Quick Start
```typescript
import { generatePdf } from 'safeer-pdf-generator';
const result = await generatePdf({
title: 'Sales Report',
data: [
{ name: 'John Doe', sales: 1200, region: 'North' },
{ name: 'Jane Smith', sales: 1500, region: 'South' },
],
columns: [
{ key: 'name', title: 'Name', dataIndex: 'name' },
{ key: 'sales', title: 'Sales', dataIndex: 'sales' },
{ key: 'region', title: 'Region', dataIndex: 'region' },
],
});
console.log(`PDF: ${result.fileName} (${result.pageCount} pages, ${result.durationMs}ms)`);
```
## 🎨 Custom Templates
### Built-in Templates
Three templates are available out of the box:
```typescript
// Modern template (gradient header, card-style info)
await generatePdf({ ...options, template: 'modern' });
// Default template (classic table with company header)
await generatePdf({ ...options, template: 'default' });
// Simple template (minimal, lightweight)
await generatePdf({ ...options, template: 'simple' });
```
### Custom CSS & HTML Injection
Override styles and add HTML blocks using any built-in template:
```typescript
const result = await generatePdf({
title: 'Styled Report',
data, columns,
template: 'modern',
customCss: `
.modern-table th {
background: linear-gradient(135deg, #7c3aed, #2563eb) !important;
color: #fff !important;
}
`,
customHtml: {
beforeTable: '<div style="padding: 12px; background: #f5f3ff;">Q4 2025 Summary</div>',
afterTable: '<div style="font-size: 11px; color: #64748b;">Confidential</div>',
},
});
```
### Inline Template Function
Full control over the generated HTML:
```typescript
const result = await generatePdf({
title: 'Custom Report',
data, columns,
template: (params) => {
const { title, data, columns } = params;
const html = `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>${title}</title></head>
<body>
<h1>${title}</h1>
<table>
<thead><tr>${columns.map(c => `<th>${c.title}</th>`).join('')}</tr></thead>
<tbody>
${data.map(row =>
`<tr>${columns.map(c => `<td>${row[c.dataIndex] ?? '—'}</td>`).join('')}</tr>`
).join('')}
</tbody>
</table>
</body>
</html>
`;
return { html, header: '', footer: '' };
},
});
```
### Registered Templates
Register a reusable template globally:
```typescript
import { registerTemplate, generatePdf } from 'safeer-pdf-generator';
registerTemplate('invoice', (params) => {
const { title, data, columns } = params;
// ... return { html, header, footer }
});
// Use it by name anywhere
await generatePdf({ ...options, template: 'invoice' });
```
## 🌐 BYOB (Bring Your Own Browser)
Reuse a shared Puppeteer browser instance across multiple generations — saves memory and startup time:
```typescript
import puppeteer from 'puppeteer';
import { generatePdf } from 'safeer-pdf-generator';
const browser = await puppeteer.launch({ headless: true });
// Generate multiple PDFs using the same browser
for (const report of reports) {
await generatePdf({
...report,
puppeteer: { browserInstance: browser },
});
}
// You manage the browser lifecycle
await browser.close();
```
> **Note:** BYOB requires `safeer-pdf-generator >= 1.3.3`. On 1.3.2 and
> earlier, passing a Browser instance via `puppeteer.browserInstance`
> throws `browser.newPage is not a function`. Upgrade or pin
> `^1.3.3`.
## 📡 Lifecycle Events
Listen for typed events during PDF generation:
```typescript
import { generatePdf, PdfEventEmitter } from 'safeer-pdf-generator';
const events = new PdfEventEmitter();
events.onEvent('generation:started', ({ title, rowCount }) => {
console.log(`Generating: ${title} (${rowCount} rows)`);
});
// Per-chunk progress — fires once per chunk during chunked generation
events.onEvent('chunk:processed', ({ chunkIndex, totalChunks, sizeBytes }) => {
const pct = (((chunkIndex + 1) / totalChunks) * 100).toFixed(0);
console.log(`Chunk ${chunkIndex + 1}/${totalChunks} done (${sizeBytes} bytes) — ${pct}%`);
});
events.onEvent('generation:complete', ({ result }) => {
console.log(`Done: ${result.pageCount} pages in ${result.durationMs}ms`);
});
events.onEvent('s3:upload:complete', ({ url }) => {
notifyClient(url);
});
events.onEvent('file:saved', ({ path, sizeBytes }) => {
console.log(`Wrote ${sizeBytes} bytes to ${path}`);
});
events.onEvent('error', ({ error, phase }) => {
alertOps(`PDF failed at ${phase}: ${error.message}`);
});
await generatePdf({ ...options, events });
```
**Available events:**
| Event | Payload | When |
|---|---|---|
| `generation:started` | `{ title, rowCount, timestamp }` | Generation begins |
| `chunk:processed` | `{ chunkIndex, totalChunks, sizeBytes }` | Each chunk finishes rendering (chunked path only) |
| `generation:complete` | `{ result }` | Final PDF assembled |
| `s3:upload:complete` | `{ url, key, requestPayload? }` | S3 upload finishes |
| `file:saved` | `{ path, sizeBytes }` | Local file written (when `localFs` is set) |
| `email:sent` | `{ to, messageId? }` | Email accepted by SMTP server |
| `error` | `{ error, phase, stack? }` | Any phase fails |
> **Note:** `chunk:processed` requires `>= 1.3.4`. `file:saved` requires `>= 1.3.6`. On earlier versions these events were declared in the type map but never fired.
## 🔔 Webhook Dispatcher
Automatically POST to your backend when a PDF is ready. Includes HMAC-SHA256 signing and retry logic:
```typescript
await generatePdf({
...options,
webhook: {
url: 'https://api.example.com/webhooks/pdf-ready',
secret: process.env.WEBHOOK_SECRET, // HMAC-SHA256 signing
metadata: { tenantId: 'acme', userId: 'u-123' }, // Pass-through data
timeoutMs: 10000,
},
});
// Your endpoint receives:
// POST { s3Url, title, fileName, sizeBytes, pageCount, durationMs, metadata }
// Headers: { X-Webhook-Signature: "sha256=abc123..." }
```
## ☁️ S3 Upload & 📧 Email
```typescript
const result = await generatePdf({
title: 'Monthly Report',
data: salesData,
columns: [
{ key: 'name', title: 'Sales Rep', dataIndex: 'name' },
{ key: 'sales', title: 'Sales', dataIndex: 'sales' },
],
s3: {
bucket: 'my-pdfs',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
email: {
to: 'user@example.com',
attachmentMode: 'link',
smtp: {
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
},
},
});
console.log(`Uploaded: ${result.s3?.url}`);
```
### S3-Compatible Storage (R2, MinIO, B2, Spaces, Wasabi)
Set the `endpoint` field. The package uses `@aws-sdk/client-s3` under the hood, so any S3-compatible provider works:
```typescript
// Cloudflare R2
s3: {
bucket: 'reports',
region: 'auto',
endpoint: 'https://<account-id>.r2.cloudflarestorage.com',
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
}
// MinIO (self-hosted)
s3: {
bucket: 'reports',
region: 'us-east-1',
endpoint: 'http://minio.internal:9000',
accessKeyId: process.env.MINIO_ACCESS_KEY,
secretAccessKey: process.env.MINIO_SECRET_KEY,
}
// Backblaze B2
s3: {
bucket: 'reports',
region: 'us-west-002',
endpoint: 'https://s3.us-west-002.backblazeb2.com',
accessKeyId: process.env.B2_KEY_ID,
secretAccessKey: process.env.B2_APP_KEY,
}
```
## 📁 Local File Output
Write the generated PDF to local disk. Composes with `s3`, `email`, and `webhook` — every destination runs on the same call:
```typescript
import { generatePdf } from 'safeer-pdf-generator';
const result = await generatePdf({
title: 'Sales Report',
data, columns,
localFs: {
path: '/var/reports', // directory; created if missing
// filename: 'custom.pdf', // optional override; defaults to result.fileName
// createDir: true, // mkdir -p (default true)
// overwrite: true, // when false, throws if target exists
},
});
console.log(`Saved: ${result.localFs?.path}`); // /var/reports/Sales-Report-2026-05-17-...pdf
```
Combine destinations freely — they all run, all populate the result, all emit their own lifecycle event:
```typescript
await generatePdf({
title, data, columns,
localFs: { path: '/var/archive' }, // local archive copy
s3: { bucket: 'cdn', region: 'us-east-1' }, // primary distribution
email: { to: 'finance@acme.com', ... }, // notify stakeholders
webhook: { url: 'https://api.acme.com/hook' }, // notify backend
});
```
> **Security note:** The `localFs.path` value is used verbatim with `fs.writeFile`. The library does not sandbox it. If the path comes from untrusted input (e.g., an HTTP request body), validate it in your code before passing it in.
>
> **Requires `>= 1.3.6`.** Earlier versions had `return: 'file'` and `outputPath` fields declared in the type, but neither was implemented — they are now marked `@deprecated` in favor of `localFs`.
## 🏗️ Framework Examples
### Express.js
```typescript
import express from 'express';
import { generatePdf } from 'safeer-pdf-generator';
const app = express();
app.post('/generate-report', async (req, res) => {
try {
const result = await generatePdf({
title: req.body.title,
data: req.body.data,
columns: req.body.columns,
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`);
res.send(result.buffer);
} catch (error) {
res.status(500).json({ error: 'PDF generation failed' });
}
});
```
### NestJS
```typescript
import { Injectable } from '@nestjs/common';
import { generatePdf, PdfEventEmitter } from 'safeer-pdf-generator';
@Injectable()
export class PdfService {
async createReport(data: any[], title: string) {
const events = new PdfEventEmitter();
events.onEvent('generation:complete', ({ result }) => {
this.logger.log(`PDF ready: ${result.fileName}`);
});
return generatePdf({
title,
data,
columns: [
{ key: 'id', title: 'ID', dataIndex: 'id' },
{ key: 'name', title: 'Name', dataIndex: 'name' },
],
template: 'modern',
events,
});
}
}
```
## 📊 Large Dataset Handling
```typescript
import { generatePdf } from 'safeer-pdf-generator';
const result = await generatePdf({
title: 'Large Report',
data: largeDataset, // 100,000+ records
columns,
chunking: {
enabled: true,
chunkSize: 500, // Rows per chunk
maxConcurrency: 4, // Parallel chunk processing
},
});
```
## 🔀 PDF Merging
```typescript
import { mergePdfs } from 'safeer-pdf-generator';
const merged = await mergePdfs({
buffers: [buffer1, buffer2, buffer3],
});
```
## 🔧 Configuration Reference
### `PdfGenerationOptions`
| Option | Type | Default | Description |
|---|---|---|---|
| `title` | `string` | **required** | Report title |
| `data` | `any[]` | **required** | Array of data rows |
| `columns` | `ColumnDefinition[]` | **required** | Column definitions |
| `template` | `string \| Function` | `'default'` | Template name or compiler function |
| `customCss` | `string` | `undefined` | CSS injected into the template |
| `customHtml` | `{ beforeTable?, afterTable? }` | `undefined` | HTML injected around the table |
| `locale` | `string` | `'en'` | Locale (`'en'`, `'ar'` for RTL) |
| `userInfo` | `UserInfo` | `undefined` | User info shown in header |
| `infoSection` | `InfoSection[]` | `undefined` | Key-value info cards |
| `chunking` | `ChunkingOptions` | `{ enabled: true, chunkSize: 100 }` | Chunk processing config |
| `s3` | `S3UploadConfig \| false` | `false` | S3 / S3-compatible upload configuration |
| `localFs` | `LocalFsConfig \| false` | `false` | Write PDF to local disk (since 1.3.6) |
| `email` | `EmailSendConfig \| false` | `false` | Email delivery configuration |
| `webhook` | `WebhookConfig \| false` | `false` | Webhook dispatch configuration |
| `events` | `PdfEventEmitter` | `undefined` | Lifecycle event emitter |
| `puppeteer` | `PuppeteerOptions` | `{ headless: true }` | Puppeteer launch options |
| `puppeteer.browserInstance` | `Browser` | `undefined` | External browser (BYOB) |
| `hooks` | `HooksConfig` | `undefined` | Sync lifecycle hooks |
| `logging` | `LoggingAdapter` | `noOpLogger` | Logger implementation |
| `timeoutMs` | `number` | `300000` | Global timeout (ms) |
### `ColumnDefinition`
| Field | Type | Description |
|---|---|---|
| `key` | `string` | Unique column identifier |
| `title` | `string` | Column header text |
| `dataIndex` | `string` | Property path in data objects (supports nested: `'address.city'`) |
| `type` | `'image' \| 'boolean' \| 'text' \| 'link'` | Optional column type for custom rendering |
### `WebhookConfig`
| Field | Type | Default | Description |
|---|---|---|---|
| `url` | `string` | **required** | Endpoint URL |
| `secret` | `string` | `undefined` | HMAC-SHA256 secret |
| `metadata` | `Record<string, any>` | `undefined` | Pass-through data |
| `timeoutMs` | `number` | `10000` | Request timeout |
### `LocalFsConfig`
| Field | Type | Default | Description |
|---|---|---|---|
| `path` | `string` | **required** | Destination directory. Relative paths resolve from `process.cwd()`. |
| `filename` | `string` | `result.fileName` | Override the auto-generated filename. |
| `createDir` | `boolean` | `true` | Create parent directories recursively if missing. |
| `overwrite` | `boolean` | `true` | When `false`, throws `LocalFsError` if the target exists. |
The corresponding result field: `result.localFs = { path: string, sizeBytes: number }` (absolute path to the written file).
## 🌟 Why Choose safeer-pdf-generator?
- **Production Ready** — Used in enterprise applications
- **Memory Efficient** — Handles massive datasets without memory issues
- **Developer Friendly** — Full TypeScript support with IntelliSense
- **Framework Agnostic** — Works with any Node.js framework
- **Full Featured** — Templates, S3, email, webhooks, events — all in one package
- **Active Maintenance** — Regular updates and community support
## 📚 Examples
| Example | Description |
|---|---|
| [simple-usage.js](examples/simple-usage.js) | Basic PDF generation |
| [custom-template.js](examples/custom-template.js) | Custom CSS, inline templates, and registry |
| [byob-and-events.js](examples/byob-and-events.js) | Browser reuse + lifecycle events |
| [local-fs-output.js](examples/local-fs-output.js) | Local disk output + `chunk:processed` progress events |
| [webhook-integration.js](examples/webhook-integration.js) | Webhook dispatch with HMAC signing |
| [express-app/](examples/express-app/) | Full Express.js integration |
| [nestjs-simplified/](examples/nestjs-simplified/) | NestJS service example |
## 📄 License
MIT © [Safeersoft](https://github.com/Safeersoft)
## 🔗 Links
- [NPM Package](https://www.npmjs.com/package/safeer-pdf-generator)
- [GitHub Repository](https://github.com/Safeersoft/pdf-reporter)
- [Issues & Support](https://github.com/Safeersoft/pdf-reporter/issues)