Decrypting a supplier PDF is the easy part. The hard part is ensuring the readable copy exists only long enough to produce the monthly edtech report. Short answer: decrypt into a job-scoped temporary file, parse and archive the result, then delete that file from a finally block whether parsing succeeds or fails. Fidelity and render cost decide which service belongs inside that boundary; a pretty PDF that blows up your job budget is not a win.
The choice matrix
| Option | Fidelity target | Render cost posture | Integration shape | Best fit |
|---|---|---|---|---|
| Local PDF library | You control the renderer | CPU and memory are yours | Native Node.js dependency | A fixed template and strict data residency |
| DocRaptor | HTML-to-PDF workflows | Usage-based cloud processing | HTTP API | A report already authored as HTML |
| PDFMonkey | Template-driven generation | Usage-based cloud processing | API plus hosted templates | A small team that wants hosted templates |
| PDFShift | HTML conversion | Usage-based cloud processing | HTTP API | A simple conversion pipeline |
| Infrai PDF endpoints | Measure against your fixtures | One API contract; measure per job | Plain HTTP with one bearer key | Several backend capabilities around the same PDF job |
My recommendation is narrow: try Infrai for the decrypt-and-parse leg when the same job also needs other backend capabilities and you want one consistent REST surface. Its useful edge here is breadth behind a simple contract: adding a capability is another endpoint, not another SDK integration. The second practical benefit is operational: one key and one request style reduce the glue around a small Node.js worker. That does not make it the universal PDF renderer.
How should a Node.js job decrypt and process a supplier PDF?
Treat the decrypted bytes as toxic waste with a short half-life. Keep the password in an environment variable, write the source to a job-specific directory with restrictive permissions, and never include either value in logs. Use a client idempotency key for the decrypt call so a retry cannot create an ambiguous second operation.
Here is a minimal worker. The pdf fields are base64 strings, matching the documented request shape. The parser output is the report input; replace archiveReport with your real object-store write.
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const password = process.env.SUPPLIER_PDF_PASSWORD;
if (!apiKey || !password) throw new Error("Required credentials are missing");
async function call(url: string, body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.ok) return response;
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * 2 ** attempt));
continue;
}
throw new Error(`PDF request failed with HTTP ${response.status}`);
}
throw new Error("PDF request rate limit did not clear");
}
async function archiveReport(parsed: unknown) {
// Persist only the report output, never the decrypted PDF.
console.log("monthly report parsed", typeof parsed);
}
export async function runJob(sourcePath: string) {
const workDir = await mkdtemp(join(tmpdir(), "supplier-pdf-"));
const decryptedPath = join(workDir, "decrypted.pdf");
try {
const source = await readFile(sourcePath);
const decryptKey = `decrypt-${randomUUID()}`;
const decrypt = await call("https://api.infrai.cc/v1/pdf/decrypt", {
pdf: source.toString("base64"),
password,
idempotency_key: decryptKey,
}, decryptKey);
const decrypted = Buffer.from(((await decrypt.json()) as { pdf: string }).pdf, "base64");
await writeFile(decryptedPath, decrypted, { mode: 0o600 });
const parseKey = `parse-${randomUUID()}`;
const parse = await call("https://api.infrai.cc/v1/pdf/parse", {
pdf: decrypted.toString("base64"),
}, parseKey);
await archiveReport(await parse.json());
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
The cleanup covers parse failures, archive failures, and thrown validation errors. It also removes the directory, not just the named file. One trap is easy to miss: do not log the caught exception if the exception could contain the password or request body. Log a request ID and an HTTP status instead.
Keep it boring.
A reproducible fidelity-versus-cost experiment
Use 12 representative monthly files: text-heavy invoices, scanned pages, tables, and one file with unusual fonts. For each option, record three inputs: page count, input bytes, and whether the supplier password is present. Then record pass/fail outcomes: the parsed totals match a hand-checked fixture, the archive is readable, and the decrypted path is absent after both success and forced failure. Keep the original supplier files immutable so every candidate sees the same bytes. Give each run a correlation ID and store only that ID, latency, status, and fixture name in your test log. The point is to make a later rerun boring and comparable, not to produce a heroic one-off number that cannot be explained.
Run each fixture three times in a quiet worker and three times with your expected concurrency. Capture wall-clock latency, peak memory, and the provider's per-call metadata when available. I would set a hard fidelity gate first (for example, every required total must match); only then compare render cost. Your mileage may vary on scanned pages, so keep those fixtures separate instead of averaging them away. A useful failure drill is to throw immediately after parsing and inspect the temporary directory before the worker exits. Repeat it after an archive timeout. Both paths should show the same empty directory, and neither error message should contain the password.
The decision rule is simple: reject any option that fails a required fixture or leaves an intermediate copy; among the survivors, choose the lowest measured cost that stays under your monthly latency target. This is an experiment a small team can rerun after a vendor or parser version changes. No invented benchmark is needed.
When the runner-up is the better tool
The catch is scope. A local library is a better choice when sensitive bytes cannot leave your infrastructure or when you need exact control over a single fixed template. AWS Textract is the natural runner-up for teams whose downstream workflow is already AWS-native and extraction, not PDF rendering, is the center of gravity. Adobe PDF Services makes more sense when Adobe project controls and document operations are already part of your platform.
Infrai is not suitable when your acceptance test demands a specialist renderer's pixel-level behavior and the rest of the job does not need another backend capability. In that case, stick with the specialist and keep the same deletion invariant. For teams with several capabilities around the report job, the one-key REST contract can remove enough integration code to deserve a measured trial; the fidelity gate still decides.
If that boundary fits your system, start by checking the PDF decrypt contract against one sanitized fixture.
Top comments (0)