Skip to content

Replace Puppeteer with FormePDF for PDF generation (fixes #630)#1086

Open
danmolitor wants to merge 6 commits intoal1abb:masterfrom
danmolitor:feat/replace-puppeteer-with-formepdf
Open

Replace Puppeteer with FormePDF for PDF generation (fixes #630)#1086
danmolitor wants to merge 6 commits intoal1abb:masterfrom
danmolitor:feat/replace-puppeteer-with-formepdf

Conversation

@danmolitor
Copy link
Copy Markdown

@danmolitor danmolitor commented Apr 2, 2026

Fixes #630

Replaces the Puppeteer/headless Chrome PDF pipeline with FormePDF —
a Rust engine compiled to WebAssembly. Both invoice templates converted
to FormePDF JSX components.

Why

Puppeteer requires a full Chrome installation, has cold start issues,
and is notoriously painful to run in serverless or containerized
environments. FormePDF runs anywhere JavaScript runs — Node, Cloudflare
Workers, the browser — with no native dependencies.

What changed

  • InvoiceTemplate1.tsx — converted to FormePDF JSX
  • InvoiceTemplate2.tsx — converted to FormePDF JSX
  • lib/pdf-helpers.ts — shared formatting helpers extracted
  • package.json@formepdf/react and @formepdf/core replace Puppeteer

Performance

Puppeteer FormePDF
Render time ~2-3s ~20ms
Binary size 200MB+ (Chrome) ~5MB (WASM)
Cold start 2-5s <100ms

Output comparison

Pixel diff is ~5.4% — driven entirely by font substitution
(Outfit → Helvetica). Layout, spacing, and data rendering are identical.

Template 1

Template 1 comparison

Template 2

Template 2 comparison

Summary by CodeRabbit

  • New Features

    • Added two new PDF invoice templates and accompanying sample data for richer invoice layouts.
  • Performance

    • Reduced invoice generation timeout to improve responsiveness for quick requests.
  • Updates

    • Switched to a PDF-native rendering pipeline for more reliable, consistent invoice PDFs and improved font handling.

Replaces the Puppeteer/headless Chrome PDF pipeline with FormePDF, a Rust engine compiled to WebAssembly. Both invoice templates converted to FormePDF JSX components.

- No Chrome dependency, no cold starts
- Renders in ~20ms vs 2-3s with Puppeteer
- Same output — pixel diff is ~5.4% driven by Outfit→Helvetica font substitution, layout is identical

Closes al1abb#630
These were accidentally created during the Puppeteer-to-FormePDF migration
and are not imported anywhere.
These were only used to generate before/after comparison images for the PR.
- Upgrade @formepdf packages to 0.8.3
- Register Outfit Regular/Medium/SemiBold/Bold via Document fonts prop
- Add font files to fonts/ directory
- Set fontFamily: "Outfit" on Page for both templates
- Add loadFont fallback for VS Code preview compatibility
- Regenerate comparison images (diff down to ~5.2%)
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 2, 2026

@danmolitor is attempting to deploy a commit to the al1abb-team Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4fe57a4b-3321-4fe3-92da-d81e1ac92c84

📥 Commits

Reviewing files that changed from the base of the PR and between 12fff36 and ae0e60f.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • app/components/templates/invoice-pdf/DynamicInvoiceTemplate.tsx
  • app/components/templates/invoice-pdf/InvoiceTemplate2.data.json
  • package.json
💤 Files with no reviewable changes (1)
  • app/components/templates/invoice-pdf/DynamicInvoiceTemplate.tsx
✅ Files skipped from review due to trivial changes (1)
  • app/components/templates/invoice-pdf/InvoiceTemplate2.data.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • package.json

📝 Walkthrough

Walkthrough

Migrates PDF generation from a Puppeteer/browser HTML pipeline to native PDF rendering with @formepdf, rewrites invoice templates as PDF components, adds PDF helpers and template data, updates dependencies and build config, and shortens the invoice generation API maxDuration.

Changes

Cohort / File(s) Summary
API Config
app/api/invoice/generate/route.ts
Reduced exported maxDuration from 60 to 10.
PDF Service
services/invoice/server/generatePdfService.ts
Removed Chromium/Puppeteer flow; now dynamically imports template and calls renderDocument(InvoiceTemplate(body)). Adds template-not-found check; returns PDF bytes without browser lifecycle.
Invoice PDF Templates
app/components/templates/invoice-pdf/InvoiceTemplate1.tsx, app/components/templates/invoice-pdf/InvoiceTemplate2.tsx
Replaced HTML/Tailwind DOM rendering with @formepdf/react components (Document/Page/View/Text/Image), runtime font loading for "Outfit", simplified date rendering, and updated styling via tw(...).
Template Data
app/components/templates/invoice-pdf/InvoiceTemplate1.data.json, app/components/templates/invoice-pdf/InvoiceTemplate2.data.json
Added two JSON sample/template payload files describing sender/receiver, details, items, totals, notes, and pdfTemplate selector.
Dynamic Template File
app/components/templates/invoice-pdf/DynamicInvoiceTemplate.tsx
Removed a non-functional comment above templateName construction; no logic or exports changed.
PDF Helpers
lib/pdf-helpers.ts
New helpers: formatNumberWithCommas(number) and isDataUrl(str) exported for PDF templates.
Variables
lib/variables.ts
Removed the exported TAILWIND_CDN constant.
Next.js Build Config
next.config.js
Removed serverExternalPackages entry for Chromium/puppeteer; added config.experiments.asyncWebAssembly = true in webpack config.
Dependencies
package.json
Added @formepdf/core, @formepdf/react, @formepdf/tailwind, @fontsource/outfit; removed puppeteer-core, puppeteer, and @sparticuz/chromium dev dep.
Puppeteer Config Removed
puppeteer.config.cjs
Deleted file (custom Puppeteer cache/config removed).

Sequence Diagram

sequenceDiagram
    participant Client
    participant APIRoute as API Route
    participant PDFService as PDF Service
    participant TemplateModule as Template Module
    participant FormePDF as `@formepdf`
    participant PDFOutput as PDF Output

    Client->>APIRoute: POST /api/invoice/generate
    APIRoute->>PDFService: generatePdfService(request)
    PDFService->>TemplateModule: dynamic import InvoiceTemplate{id}
    TemplateModule-->>PDFService: Template component
    PDFService->>FormePDF: renderDocument(InvoiceTemplate(data))
    FormePDF->>FormePDF: Load fonts (Outfit via fs)
    FormePDF->>FormePDF: Render PDF to bytes
    FormePDF-->>PDFService: PDF bytes
    PDFService-->>APIRoute: Response with PDF blob
    APIRoute-->>Client: 200 OK (application/pdf)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped from HTML to formePDF delight,
Fonts snug in pockets, no browser in sight,
Templates now render in tidy bytes,
Quicker invoices under moonlit nights,
A thump, a nibble — PDF done right.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR contains one out-of-scope change: reducing maxDuration from 60 to 10 in the invoice API route, which is unrelated to the Puppeteer-to-FormePDF migration objective. Remove or justify the maxDuration reduction separately, as it falls outside the scope of the FormePDF migration and may impact PDF generation reliability.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: replacing Puppeteer with FormePDF for PDF generation, and references the linked issue.
Linked Issues check ✅ Passed The PR successfully replaces Puppeteer with FormePDF for direct PDF generation from structured data, eliminating HTML rendering, reducing dependencies, and improving cloud compatibility as required [#630].

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (5)
services/invoice/server/generatePdfService.ts (1)

17-18: Consider wrapping req.json() in try-catch for malformed request handling.

If the request body is malformed JSON, req.json() will throw, but this happens outside the try-catch block. The error will propagate as an unhandled exception rather than returning a structured 400 error.

Wrap JSON parsing in error handling
 export async function generatePdfService(req: NextRequest) {
-    const body: InvoiceType = await req.json();
-
     try {
+        const body: InvoiceType = await req.json();
         const { renderDocument } = await import("@formepdf/core");

Or add a dedicated parse block:

 export async function generatePdfService(req: NextRequest) {
+    let body: InvoiceType;
+    try {
+        body = await req.json();
+    } catch {
+        return new NextResponse(
+            JSON.stringify({ error: "Invalid JSON body" }),
+            { status: 400, headers: { "Content-Type": "application/json" } }
+        );
+    }
+
     try {
-        const body: InvoiceType = await req.json();
         const { renderDocument } = await import("@formepdf/core");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/invoice/server/generatePdfService.ts` around lines 17 - 18, The JSON
parse of the request body in generatePdfService (const body: InvoiceType = await
req.json()) can throw on malformed payloads; wrap the req.json() call in its own
try-catch (or add a small parse block) to catch JSON parsing errors and return a
structured 400 response (including a clear error message) instead of letting the
exception propagate; ensure you still validate/cast to InvoiceType and only
proceed to the existing logic if parsing succeeds.
app/components/templates/invoice-pdf/InvoiceTemplate1.tsx (2)

143-143: Use strict equality (!== undefined) for consistency.

Lines 143, 157, and 171 use loose equality (!= undefined). While this works (catching both null and undefined), using strict equality is more explicit and aligns with TypeScript best practices.

-{details.discountDetails?.amount != undefined && details.discountDetails?.amount > 0 && (
+{details.discountDetails?.amount !== undefined && details.discountDetails?.amount > 0 && (

Also applies to: 157-157, 171-171

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/templates/invoice-pdf/InvoiceTemplate1.tsx` at line 143,
Replace the loose null/undefined checks for discount amount with strict
comparisons: locate the conditional expressions that use
details.discountDetails?.amount != undefined (appearing in InvoiceTemplate1.tsx
around the rendering conditionals) and change them to
details.discountDetails?.amount !== undefined (also update the two other
occurrences noted) so the code uses strict equality for clarity and TypeScript
best practices.

22-29: Font loading error handling silently falls back without logging.

When font loading fails, the error is silently caught and a fallback path is used. In production, if font files are missing or fs access fails unexpectedly, this could result in PDF rendering issues that are hard to diagnose.

Consider adding a warning log for debugging
 function loadFont(file: string): string | Buffer {
 	try {
 		return readFileSync(join(fontsDir, file));
-	} catch {
+	} catch (err) {
 		// VS Code preview: fs unavailable or wrong cwd — use relative path
+		if (process.env.NODE_ENV !== 'development') {
+			console.warn(`Font loading fallback for ${file}:`, err);
+		}
 		return join("../../../../fonts", file);
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/templates/invoice-pdf/InvoiceTemplate1.tsx` around lines 22 -
29, The loadFont function swallows errors and silently returns a fallback path;
modify loadFont(file: string) to catch the error into a variable (from
readFileSync(join(fontsDir, file))) and call a logger/warn (e.g., console.warn
or the existing logger) including the file name, fontsDir, and the caught error
before returning the current relative fallback path; keep the existing fallback
behavior but ensure you reference the same symbols (loadFont, fontsDir,
readFileSync, join) so missing fonts or fs issues are logged for debugging.
app/components/templates/invoice-pdf/InvoiceTemplate2.tsx (2)

48-53: Minor: Redundant empty string check.

The condition details.invoiceLogo && details.invoiceLogo !== "" has a redundant check — an empty string is falsy, so the first part already excludes it.

Simplify condition
-						{details.invoiceLogo && details.invoiceLogo !== "" && isDataUrl(details.invoiceLogo) ? (
+						{details.invoiceLogo && isDataUrl(details.invoiceLogo) ? (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/templates/invoice-pdf/InvoiceTemplate2.tsx` around lines 48 -
53, Remove the redundant empty-string check in the conditional that renders the
logo: replace the combined check using details.invoiceLogo &&
details.invoiceLogo !== "" with a single truthy check plus the data-url
validator (i.e., use details.invoiceLogo && isDataUrl(details.invoiceLogo)) in
the JSX within InvoiceTemplate2 (the block that renders <Image
src={details.invoiceLogo} .../>), ensuring the same rendering behavior is
preserved.

143-156: Consider strict equality for undefined checks.

Using != undefined (loose equality) matches both undefined and null. If this is intentional to handle nullable fields, it's fine. If the intent is only to check for undefined, prefer !== undefined for clarity. This pattern repeats for tax (line 157) and shipping (line 171).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/templates/invoice-pdf/InvoiceTemplate2.tsx` around lines 143 -
156, Replace loose undefined checks using != undefined with strict !== undefined
for the discount, tax, and shipping amount checks in InvoiceTemplate2.tsx:
update the conditional that reads details.discountDetails?.amount != undefined
to details.discountDetails?.amount !== undefined and do the same for the similar
checks for tax and shipping amounts so the conditions only match actual
undefined (not null); keep the existing null-safe optional chaining
(details.discountDetails?.amount) and preserve the surrounding logic in the
render blocks (refer to the discount block, the tax block, and the shipping
block).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/components/templates/invoice-pdf/DynamicInvoiceTemplate.tsx`:
- Around line 15-24: The dynamic import in DynamicInvoice (created inside
useMemo, using previewName = `InvoicePreview${props.details.pdfTemplate}`)
points to non-existent files (InvoicePreview1/2); change the import target to
match existing components (e.g., use
`InvoiceTemplate${props.details.pdfTemplate}`) or create corresponding
InvoicePreview<id>.tsx preview components if previews must differ from
server-side templates; update the previewName and the dynamic import call
accordingly so they align with lib/helpers.ts which uses
InvoiceTemplate${templateId}.

In `@app/components/templates/invoice-pdf/InvoiceTemplate2.data.json`:
- Line 73: The pdfTemplate field in InvoiceTemplate2.data.json is wrongly set to
1 causing the sample data to load Template1; update the pdfTemplate value to 2
in InvoiceTemplate2.data.json so the file correctly targets Template2 and verify
any code that reads pdfTemplate (e.g., template selection logic) will now pick
the intended template.

In `@app/components/templates/invoice-pdf/InvoiceTemplate2.tsx`:
- Around line 216-221: The payment info block in InvoiceTemplate2 is rendering
literal "undefined" when details.paymentInformation or its fields are missing;
update the JSX so the View/Text group is only rendered when
details.paymentInformation is truthy (conditional rendering around the View), or
alternatively replace each interpolation (e.g.,
details.paymentInformation?.bankName, accountName, accountNumber) with
nullish-coalescing fallbacks like an empty string or "N/A"; locate the block
using the View/Text elements in the InvoiceTemplate2 component and apply one of
these fixes to avoid showing "undefined".

In `@package.json`:
- Line 50: The package.json currently lists the unused dependency "puppeteer"
which should be removed; open package.json and delete the "puppeteer":
"^24.40.0" entry, then run your package manager (npm install or yarn install) to
update the lockfile (package-lock.json or yarn.lock) and node_modules, verify
there are no imports/usages of "puppeteer" in the repo, and commit the updated
package.json and lockfile to complete the removal.

---

Nitpick comments:
In `@app/components/templates/invoice-pdf/InvoiceTemplate1.tsx`:
- Line 143: Replace the loose null/undefined checks for discount amount with
strict comparisons: locate the conditional expressions that use
details.discountDetails?.amount != undefined (appearing in InvoiceTemplate1.tsx
around the rendering conditionals) and change them to
details.discountDetails?.amount !== undefined (also update the two other
occurrences noted) so the code uses strict equality for clarity and TypeScript
best practices.
- Around line 22-29: The loadFont function swallows errors and silently returns
a fallback path; modify loadFont(file: string) to catch the error into a
variable (from readFileSync(join(fontsDir, file))) and call a logger/warn (e.g.,
console.warn or the existing logger) including the file name, fontsDir, and the
caught error before returning the current relative fallback path; keep the
existing fallback behavior but ensure you reference the same symbols (loadFont,
fontsDir, readFileSync, join) so missing fonts or fs issues are logged for
debugging.

In `@app/components/templates/invoice-pdf/InvoiceTemplate2.tsx`:
- Around line 48-53: Remove the redundant empty-string check in the conditional
that renders the logo: replace the combined check using details.invoiceLogo &&
details.invoiceLogo !== "" with a single truthy check plus the data-url
validator (i.e., use details.invoiceLogo && isDataUrl(details.invoiceLogo)) in
the JSX within InvoiceTemplate2 (the block that renders <Image
src={details.invoiceLogo} .../>), ensuring the same rendering behavior is
preserved.
- Around line 143-156: Replace loose undefined checks using != undefined with
strict !== undefined for the discount, tax, and shipping amount checks in
InvoiceTemplate2.tsx: update the conditional that reads
details.discountDetails?.amount != undefined to details.discountDetails?.amount
!== undefined and do the same for the similar checks for tax and shipping
amounts so the conditions only match actual undefined (not null); keep the
existing null-safe optional chaining (details.discountDetails?.amount) and
preserve the surrounding logic in the render blocks (refer to the discount
block, the tax block, and the shipping block).

In `@services/invoice/server/generatePdfService.ts`:
- Around line 17-18: The JSON parse of the request body in generatePdfService
(const body: InvoiceType = await req.json()) can throw on malformed payloads;
wrap the req.json() call in its own try-catch (or add a small parse block) to
catch JSON parsing errors and return a structured 400 response (including a
clear error message) instead of letting the exception propagate; ensure you
still validate/cast to InvoiceType and only proceed to the existing logic if
parsing succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66b7a0f4-9eb7-4948-b3c6-5cc6c694c9eb

📥 Commits

Reviewing files that changed from the base of the PR and between 0744185 and 12fff36.

⛔ Files ignored due to path filters (17)
  • .github/template-1-after.pdf is excluded by !**/*.pdf
  • .github/template-1-after.png is excluded by !**/*.png
  • .github/template-1-before.pdf is excluded by !**/*.pdf
  • .github/template-1-before.png is excluded by !**/*.png
  • .github/template-1-comparison.png is excluded by !**/*.png
  • .github/template-1-diff.png is excluded by !**/*.png
  • .github/template-2-after.pdf is excluded by !**/*.pdf
  • .github/template-2-after.png is excluded by !**/*.png
  • .github/template-2-before.pdf is excluded by !**/*.pdf
  • .github/template-2-before.png is excluded by !**/*.png
  • .github/template-2-comparison.png is excluded by !**/*.png
  • .github/template-2-diff.png is excluded by !**/*.png
  • fonts/Outfit-Bold.ttf is excluded by !**/*.ttf
  • fonts/Outfit-Medium.ttf is excluded by !**/*.ttf
  • fonts/Outfit-Regular.ttf is excluded by !**/*.ttf
  • fonts/Outfit-SemiBold.ttf is excluded by !**/*.ttf
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • app/api/invoice/generate/route.ts
  • app/components/templates/invoice-pdf/DynamicInvoiceTemplate.tsx
  • app/components/templates/invoice-pdf/InvoiceTemplate1.data.json
  • app/components/templates/invoice-pdf/InvoiceTemplate1.tsx
  • app/components/templates/invoice-pdf/InvoiceTemplate2.data.json
  • app/components/templates/invoice-pdf/InvoiceTemplate2.tsx
  • lib/pdf-helpers.ts
  • lib/variables.ts
  • next.config.js
  • package.json
  • puppeteer.config.cjs
  • services/invoice/server/generatePdfService.ts
💤 Files with no reviewable changes (2)
  • lib/variables.ts
  • puppeteer.config.cjs

Comment thread app/components/templates/invoice-pdf/DynamicInvoiceTemplate.tsx Outdated
Comment thread app/components/templates/invoice-pdf/InvoiceTemplate2.data.json Outdated
Comment thread app/components/templates/invoice-pdf/InvoiceTemplate2.tsx
Comment thread package.json Outdated
…remove puppeteer

- Fix DynamicInvoiceTemplate to import InvoiceTemplate (not deleted InvoicePreview)
- Fix InvoiceTemplate2.data.json pdfTemplate from 1 to 2
- Remove unused puppeteer dependency
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
invoify Error Error Apr 2, 2026 0:12am

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Simplify PDF Generation – Avoid Puppeteer Overhead

1 participant