TypeScript/JavaScript client for the Folio serverless PDF API.
npm install folio-clientimport { FolioClient } from "folio-client";
const folio = new FolioClient({
baseUrl: "http://localhost:8080",
// apiKey is only required when the server has API_KEY configured
apiKey: "your-api-key-minimum-32-characters",
});Store the result in S3 and receive a presigned download URL:
const { data } = await folio.generate({
html: "<html><body><h1>Hello, Folio</h1></body></html>",
css: "h1 { color: navy }",
paper: { size: "A4", orientation: "portrait" },
options: {
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
printBackground: true,
},
});
console.log(data.url); // presigned S3 URL
console.log(data.id); // UUID for subsequent operationsProvide exactly one of html or url — the types enforce it (supplying both, or
neither, is a compile error).
Stream the raw PDF bytes (ReadableStream<Uint8Array>); buffer with collect if you
want all the bytes in memory:
import { collect } from "folio-client";
const stream = await folio.generateStream({ html: "<h1>Hello</h1>" });
// pipe `stream` to a file/HTTP response, or buffer it:
const bytes = await collect(stream); // Uint8ArrayRender a remote URL instead of inline HTML — with optional cookies and headers for authenticated pages:
const { data } = await folio.generate({
url: "https://example.com/invoice/42",
cookies: [{ name: "session", value: "…", domain: "example.com" }],
extraHeaders: { Authorization: "Bearer …" },
});const { data } = await folio.get(id); // { url } — a fresh presigned URL
await folio.delete(id); // 204 No ContentCombine 2–20 stored PDFs into one:
const { data } = await folio.merge({ ids: [id1, id2, id3] });Extract a page range (requires Ghostscript on the server):
const { data } = await folio.split({ id, pages: "1-3" });const { data } = await folio.compress({ id });Conformance levels: "1b", "2b" (default), "3b":
const { data } = await folio.pdfA({ id, conformance: "2b" });Render HTML or a URL to an image (PNG/JPEG/WebP) — store it and get a URL, or stream the raw bytes:
const { data } = await folio.screenshot({
url: "https://example.com",
viewport: { width: 1280, height: 720 },
format: "png",
fullPage: true,
});
console.log(data.url); // presigned S3 URL
const stream = await folio.screenshotStream({ html: "<h1>Hi</h1>" });
// stream is a ReadableStream<Uint8Array>/health is public — the client never sends the API key to it, even when one is configured.
const { status } = await folio.health(); // { status: "ok" }| Option | Type | Description |
|---|---|---|
baseUrl |
string |
Base URL of your Folio instance |
apiKey |
string? |
Sent as X-Api-Key; required only when the server has API_KEY set |
timeout |
number? |
Request timeout in ms (default: 30_000) |
retry |
RetryOptions | false? |
Retry policy for 429/503 + transient network errors (default: 2 retries, exponential backoff + jitter, honors Retry-After). false disables |
The store-to-S3 methods (generate, merge, split, compress, pdfA) return Promise<FolioResponse<StoredPdf>> ({ id, url }). get(id) returns FolioResponse<StoredUrl> ({ url }), screenshot returns FolioResponse<StoredImage>, delete resolves to void, and health() returns { status }. Each producing method has a *Stream counterpart returning Promise<ReadableStream<Uint8Array>> for the raw bytes — buffer one with collect(stream).
| Method | Endpoint | Returns |
|---|---|---|
generate(body) / generateStream(body) |
POST /pdf/generate |
FolioResponse<StoredPdf> / ReadableStream |
get(id) |
GET /pdf/:id |
FolioResponse<StoredUrl> |
delete(id) |
DELETE /pdf/:id |
void |
merge(body) / mergeStream(body) |
POST /pdf/merge |
FolioResponse<StoredPdf> / ReadableStream |
split(body) / splitStream(body) |
POST /pdf/split |
FolioResponse<StoredPdf> / ReadableStream |
compress(body) / compressStream(body) |
POST /pdf/compress |
FolioResponse<StoredPdf> / ReadableStream |
pdfA(body) / pdfAStream(body) |
POST /pdf/pdfa |
FolioResponse<StoredPdf> / ReadableStream |
screenshot(body) / screenshotStream(body) |
POST /screenshot |
FolioResponse<StoredImage> / ReadableStream |
health() |
GET /health |
{ status } |
Every failure throws a FolioError or one of its subclasses, so a single
instanceof FolioError catches them all:
import { FolioError, FolioTimeoutError, FolioNetworkError } from "folio-client";
try {
await folio.get("nonexistent-id");
} catch (err) {
if (err instanceof FolioTimeoutError) {
// request exceeded `timeout` (statusCode 0)
} else if (err instanceof FolioNetworkError) {
// DNS / connection / TLS failure before any HTTP status (cause on err.body)
} else if (err instanceof FolioError) {
console.error(err.statusCode, err.body); // non-2xx HTTP response
}
}Every method accepts a trailing options.signal to cancel from the caller side
(combined with the client timeout). A caller-initiated abort surfaces as the
native AbortError; only the timeout becomes a FolioTimeoutError.
const controller = new AbortController();
const promise = folio.generate(
{ url: "https://slow.example" },
{ signal: controller.signal }
);
controller.abort(); // promise rejects with AbortErrorTransient failures (429/503 and network errors) are retried automatically with
exponential backoff + jitter; a Retry-After header is honored. Timeouts are never
retried. Configure on the client or per call, or disable with retry: false:
const folio = new FolioClient({
baseUrl,
retry: { maxRetries: 3, retryOn: [429, 503, 504], baseDelayMs: 300 },
});
// override or disable for a single call (e.g. where a duplicate would matter):
await folio.merge({ ids }, { retry: false });Note: a network-error retry is at-least-once — if a request was processed but the response was lost, the retry can create a duplicate. Pass
retry: falseon calls where that matters.
git clone https://github.com/alegerber/folio
cd folio
cp .env.example .env
docker compose upThe API is then available at http://localhost:8080.
npm run build # compile to dist/
npm test # run tests
npm run typecheck # type-check without emittingMIT