PDF Export Pipeline
Source: src/app/[locale]/[id]/pdf/route.ts
The PDF export route converts a rendered activity declaration or information clause into a downloadable PDF. It reuses the same HTML generation functions as the HTML route, then pipes the output through Playwright and pdf-lib.
Prerequisites
Two runtime constraints are set at the top of the route file:
export const runtime = "nodejs"; // Edge runtime cannot run Playwright
export const dynamic = "force-dynamic"; // never cache — each PDF is generated fresh
Feature flag: the route returns 404 unless ENABLE_SERVER_PDF=true is set in the environment.
Request
GET /{locale}/{activityId}/pdf?type=activity-declaration|activity-information
type defaults to activity-information when omitted.
Full request lifecycle
Step 1 — Turnstile verification
Every PDF request must pass a Cloudflare Turnstile CAPTCHA challenge before any generation starts.
- No token present: the route returns an HTML page with an embedded Turnstile widget. The widget POSTs back to the same URL with the token once the challenge is solved.
- Token present: the route calls
verifyTurnstileToken(request). An invalid or expired token returns 401/403. - Valid token: generation proceeds.
Step 2 — HTML generation
The route calls either generateActivityDeclaration or generateActivityInformation (from src/lib/html/). Both functions:
- Extract
shortNamefrom the request hostname viagetHostnameServer(). - Fetch the
Organizationdocument byshortName. - Find the ROPA reference in
Organization.ropas[]matching the target locale. - Fetch the
Ropadocument. - Find the activity in
ous[].activities[]byactivityId. - Load the template via
reloadTemplateRO()— see template lookup. - Build the context object (translated enum values, partner names, logo as base64 data URL, etc.).
- Compile and render the Handlebars template.
- Return
{ isError, data: htmlString, metadata: { orgShortname, orgLongName, activityId, activityName, locale } }.
Step 3 — Playwright: HTML → PDF
const browser = await playwrightChromium.launch({
args: chromium.args,
executablePath: await resolveExecutablePath(chromium),
headless: true,
});
const page = await browser.newPage();
await page.setContent(htmlString, { waitUntil: "networkidle" });
const pdfBuffer = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "20mm", right: "15mm", bottom: "20mm", left: "15mm" },
});
Executable resolution (resolveExecutablePath): checks CHROME_EXECUTABLE_PATH, then PUPPETEER_EXECUTABLE_PATH, then the bundled path from @sparticuz/chromium. Throws if none are found.
browser.close() is always called in a finally block to prevent resource leaks.
Step 4 — pdf-lib: metadata injection
const pdfDoc = await PDFDocument.load(pdfBuffer);
pdfDoc.setTitle(`${orgShortname}-${activityName} Art. 30 Declaration`);
pdfDoc.setAuthor("GDPR Labs");
pdfDoc.setSubject("GDPR Article 30 Processing Activity Declaration");
pdfDoc.setKeywords(["GDPR", "article 30", "declaration", "ROPA", orgLongName]);
pdfDoc.setCreator("Register of Processing Activities by GDPR Labs");
pdfDoc.setProducer("Register of Processing Activities by GDPR Labs");
pdfDoc.setCreationDate(new Date());
pdfDoc.setModificationDate(new Date());
const finalPdf = await pdfDoc.save();
Step 5 — Response
Content-Type: application/pdf
Content-Disposition: inline; filename="{shortName}-{decl|info}-{activityId}-{locale}.pdf"
Cache-Control: private, max-age=0, must-revalidate
Example filename: acme-decl-42-en.pdf (declaration, activity 42, English).
Error responses
| Condition | Status |
|---|---|
ENABLE_SERVER_PDF not set | 404 |
| Turnstile token missing | 200 (HTML challenge page) |
| Turnstile token invalid | 401 / 403 |
| Activity not found | 404 |
| HTML generation failure | 500 |
| Missing Turnstile configuration | 500 |