@workkit/pdf
v0.1.1
Published
Render HTML to PDF in Cloudflare Workers via @workkit/browser; R2 storage and presign helpers
Maintainers
Readme
@workkit/pdf
Render HTML to PDF in Cloudflare Workers via @workkit/browser. Includes R2 storage + presign helpers, page/margin presets, and a header/footer composer with HTML escaping by default.
Install
bun add @workkit/pdf @workkit/browser @cloudflare/puppeteerQuick start
import puppeteer from "@cloudflare/puppeteer";
import { browser } from "@workkit/browser";
import { renderPDF, storedPDF, raw } from "@workkit/pdf";
export default {
async fetch(req: Request, env: Env) {
const session = await browser(env.BROWSER, { puppeteer });
// Pure render
const bytes = await renderPDF(session, "<h1>Brief</h1>", {
header: { title: "NIFTY", right: new Date().toISOString() },
footer: { disclaimer: "Not investment advice", pageNumbers: true },
disclaimerRequired: true,
});
// Render + store + presign
const { r2Key, url } = await storedPDF(session, "<h1>Brief</h1>", {
bucket: env.REPORTS,
key: ["reports", "user-1", `${Date.now()}.pdf`],
metadata: { userId: "u1", reportId: "r1" },
presignTtl: 3600,
});
return new Response(JSON.stringify({ r2Key, url, size: bytes.byteLength }));
},
};API
renderPDF(session, html, options?)
Returns Promise<Uint8Array>. Uses @workkit/browser's withPage so JS-off, dialog auto-dismiss, abort propagation, and guaranteed page close come for free.
Options:
page—pageSize.A4 | Letter | Legal. DefaultA4.margin—string | Partial<PageMargin> | PageMargin. String applies to all sides.header/footer— composed viacomposeHeaderFooter().disclaimerRequired: true— fails fast iffooter.disclaimeris empty.fonts—FontDescriptor[]preloaded via@workkit/browser'sloadFonts().signal—AbortSignal.js: true— opt-in JS execution (off by default).timeoutMs— per-render timeout.waitUntil— Puppeteer setContent wait state. Defaultnetworkidle2.printBackground— defaulttrue.scale— Puppeteer page scale factor.
storedPDF(session, html, options)
Render → R2 upload → presign in one call. Returns { r2Key, bytes, url }.
Additional options on top of RenderPdfOptions:
bucket—R2Bucket-shaped binding (must implementputand, whenreadPolicy: "presigned",createPresignedUrl).key—stringorstring[](joined viasafeKey()).metadata—Record<string,string>forwarded tocustomMetadata.readPolicy—"presigned"(default) or"private"(skips presign, returnsurl: null).presignTtl— seconds. Default 3600. Hard cap 86400 (24h) — exceeding throwsValidationError.contentDisposition— overrides theContent-Dispositionheader on the stored object.
Header / footer composition
import { composeHeaderFooter, raw, escapeHtml } from "@workkit/pdf";
composeHeaderFooter({
header: {
logo: raw('<img src="https://cdn.example.com/logo.png" />'), // raw HTML
title: "NIFTY", // auto-escaped
right: new Date().toISOString(), // auto-escaped
},
footer: {
disclaimer: "Not investment advice. SEBI Reg No: …",
pageNumbers: true,
},
disclaimerRequired: true,
});Plain strings auto-escape. Use raw() only for HTML you produced or verified yourself.
safeKey(...parts)
Joins parts with / after rejecting .., ., \, control chars, and components that reduce to empty after slash trim. Throws ValidationError rather than silently sanitizing.
Security defaults
- Header/footer values escape by default — only
raw()opt-in passes through unescaped. - R2 keys validated —
safeKey()rejects path traversal explicitly. No silent transforms. - Presigned URL TTL capped at 24h — bearer-token blast radius. Use
readPolicy: "private"for longer-lived access. - JS off by default — inherits from
@workkit/browser. disclaimerRequiredcompliance hook — fails before render, not after.- No HTML body content logged — caller's logger sees
r2Key,bytes, durations only.
Cost monitoring
Browser Rendering is priced per session. Recommended pattern:
- Wire
@workkit/ratelimitper user before callingrenderPDFto bound spend. - Increment an Analytics Engine counter on every render call.
- Alert at 50,000 sessions / month as a sanity ceiling.
- If you cross that threshold consistently, evaluate
@react-pdf/renderer(pure JS) for templated content where Browser Rendering's full layout engine isn't needed.
Versioning
Follows the workkit Constitution — single src/index.ts export, no cross-package imports outside declared peer deps. Changesets accompany every public API change.
