// Merge PDFs with Node.js 20+ and its built-in HTTP APIs. No npm packages needed. // Usage: node sdk/node/examples/merge.mjs --max-credits 5 first.pdf second.pdf import { createHash, randomUUID } from "node:crypto"; import { createReadStream } from "node:fs"; import { mkdir, open, stat, unlink } from "node:fs/promises"; import path from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { parseArgs } from "node:util"; import { createInterface } from "node:readline/promises"; const POLL_INTERVAL_MS = 2_000; const POLL_TIMEOUT_MS = 15 * 60_000; const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]); function usage() { return `Usage: node sdk/node/examples/merge.mjs --max-credits N [--output DIR] [--base-url URL] [--yes] first.pdf second.pdf [...] Set SOFTWAREFOOL_API_KEY to a key with jobs:submit, wallet:read, jobs:read, and results:read. --base-url defaults to https://softwarefool.com/api/v1; for local development use http://localhost:8001/api/v1. --yes approves the displayed quote without a keyboard prompt (useful for scripts).`; } function apiBaseUrl(value) { const url = new URL(value); const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); const pathname = url.pathname.replace(/\/$/, ""); if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.search || url.hash || !pathname.endsWith("/api/v1")) { throw new Error("Use an HTTPS /api/v1 URL, or HTTP /api/v1 on localhost only."); } return new URL(`${url.origin}${pathname}/`); } function apiLink(base, link) { if (typeof link !== "string" || !link) throw new Error("The API returned an invalid link."); const url = new URL(link, base); if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname) || url.username || url.password || url.hash) { throw new Error("The API returned a link outside its own API origin."); } return url; } async function snapshot(file) { const info = await stat(file); if (!info.isFile() || info.size < 1 || !Number.isSafeInteger(info.size)) { throw new Error(`Input must be a nonempty regular file: ${file}`); } const digest = createHash("sha256"); let bytes = 0; for await (const chunk of createReadStream(file)) { bytes += chunk.length; digest.update(chunk); } return { file, bytes, sha256: digest.digest("hex") }; } async function ensureUnchanged(files) { for (const original of files) { const current = await snapshot(original.file); if (current.bytes !== original.bytes || current.sha256 !== original.sha256) { throw new Error(`Input changed after the quote: ${original.file}`); } } } function apiHeaders(key, idempotencyKey) { const headers = { Authorization: `Bearer ${key}`, Accept: "application/json", }; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; return headers; } async function jsonResponse(response) { if (response.status >= 300 && response.status < 400) { throw new Error(`Unexpected API redirect (${response.status}).`); } let payload; try { payload = await response.json(); } catch { throw new Error(`The API returned invalid JSON (${response.status}).`); } if (!response.ok) { const code = payload?.error?.code; throw new Error(`The API rejected the request (${response.status}, ${typeof code === "string" ? code : "request_failed"}).`); } return payload; } function multipartUpload(token, files) { if (typeof token !== "string" || !token || /[\r\n]/.test(token)) { throw new Error("The API returned an invalid quote token."); } const boundary = `softwarefool-${randomUUID()}`; const first = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="quote_token"\r\n\r\n${token}\r\n`); const parts = files.map((entry, index) => ({ file: entry.file, bytes: entry.bytes, header: Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="input-${String(index + 1).padStart(4, "0")}.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), })); const ending = Buffer.from(`--${boundary}--\r\n`); const length = first.length + ending.length + parts.reduce((total, item) => total + item.header.length + item.bytes + 2, 0); async function* chunks() { yield first; for (const item of parts) { yield item.header; yield* createReadStream(item.file); yield Buffer.from("\r\n"); } yield ending; } return { body: Readable.from(chunks()), contentType: `multipart/form-data; boundary=${boundary}`, length, }; } async function confirmQuote(base, key, idempotencyKey, token, files) { // An uncertain response can be retried within this process with the same // token, key, and ordered bytes. A fresh stream is built for each attempt. for (let attempt = 1; attempt <= 3; attempt += 1) { await ensureUnchanged(files); const upload = multipartUpload(token, files); try { const response = await fetch(new URL("tools/merge-pdf/quotes/confirm/", base), { method: "POST", headers: { ...apiHeaders(key, idempotencyKey), "Content-Type": upload.contentType, "Content-Length": String(upload.length), }, body: upload.body, duplex: "half", redirect: "manual", signal: AbortSignal.timeout(10 * 60_000), }); if (response.status >= 500 && attempt < 3) { await response.body?.cancel(); continue; } return (await jsonResponse(response)).data; } catch (error) { if (attempt === 3 || !["TypeError", "TimeoutError", "AbortError"].includes(error?.name)) throw error; } } throw new Error("Confirmation failed; its outcome may be unknown."); } async function pollJob(base, key, job) { const jobUrl = apiLink(base, job?.links?.self); const id = job?.id; if (typeof id !== "string" || !id) throw new Error("The API returned a job without an ID."); console.log(`Job ID: ${id}`); const deadline = Date.now() + POLL_TIMEOUT_MS; let previous; while (Date.now() < deadline) { const response = await fetch(jobUrl, { headers: apiHeaders(key), redirect: "manual", signal: AbortSignal.timeout(30_000), }); job = (await jsonResponse(response)).data; if (job?.id !== id) throw new Error("The API returned a different job."); if (job.status !== previous) { console.log(`Status: ${job.status}`); previous = job.status; } if (TERMINAL_STATUSES.has(job.status)) return job; await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } throw new Error(`Job ${id} is still processing. Keep its ID and check it later.`); } function safeFilename(filename) { if (typeof filename !== "string" || !filename || filename === "." || filename === ".." || filename.includes("/") || filename.includes("\\") || filename.includes("\0")) { throw new Error("The API returned an unsafe result filename."); } return filename; } async function downloadResults(base, key, job, outputDir) { const list = await fetch(apiLink(base, job?.links?.results), { headers: apiHeaders(key), redirect: "manual", signal: AbortSignal.timeout(30_000), }); const results = (await jsonResponse(list)).data; if (!Array.isArray(results)) throw new Error("The API returned an invalid result list."); const available = results.filter((item) => item?.status === "available" && item?.links?.download); if (!available.length) throw new Error("No downloadable result is available."); await mkdir(outputDir, { recursive: true }); for (const result of available) { const destination = path.join(outputDir, safeFilename(result.filename)); const response = await fetch(apiLink(base, result.links.download), { headers: apiHeaders(key), redirect: "manual", signal: AbortSignal.timeout(10 * 60_000), }); if (!response.ok || response.status >= 300 || !response.body) { await jsonResponse(response); throw new Error("The API did not return a downloadable result."); } if (!Number.isSafeInteger(result.bytes) || result.bytes < 0) { throw new Error("The API returned an invalid result size."); } // wx refuses to overwrite an existing file. Remove only a partial file // created by this invocation if the transfer fails. const output = await open(destination, "wx"); let completed = false; try { let downloaded = 0; const count = new Transform({ transform(chunk, _encoding, callback) { downloaded += chunk.length; callback(downloaded <= result.bytes ? null : new Error("Result exceeds its stated size."), chunk); }, }); await pipeline(Readable.fromWeb(response.body), count, output.createWriteStream()); if (downloaded !== result.bytes) throw new Error("Result download was incomplete."); completed = true; console.log(`Saved: ${destination}`); } finally { await output.close(); if (!completed) await unlink(destination).catch(() => {}); } } } async function main() { const { values, positionals } = parseArgs({ options: { "max-credits": { type: "string" }, output: { type: "string", default: "./results" }, "base-url": { type: "string", default: process.env.SOFTWAREFOOL_API_BASE_URL || "https://softwarefool.com/api/v1" }, yes: { type: "boolean", default: false }, help: { type: "boolean", default: false }, }, allowPositionals: true, }); if (values.help) { console.log(usage()); return; } const budget = Number(values["max-credits"]); if (positionals.length < 2 || !Number.isSafeInteger(budget) || budget < 1) { throw new Error(usage()); } const key = process.env.SOFTWAREFOOL_API_KEY; if (!key || /\s/.test(key)) throw new Error("Set SOFTWAREFOOL_API_KEY to your bearer key."); const base = apiBaseUrl(values["base-url"]); const files = []; for (const file of positionals) files.push(await snapshot(file)); const idempotencyKey = randomUUID(); const quoteResponse = await fetch(new URL("tools/merge-pdf/quotes/", base), { method: "POST", headers: { ...apiHeaders(key, idempotencyKey), "Content-Type": "application/json" }, body: JSON.stringify({ files: files.map(({ bytes, sha256 }) => ({ bytes, sha256 })), parameters: {} }), redirect: "manual", signal: AbortSignal.timeout(30_000), }); const quote = (await jsonResponse(quoteResponse)).data; if (typeof quote?.token !== "string" || !Number.isSafeInteger(quote.cost_credits) || typeof quote.can_afford !== "boolean") { throw new Error("The API returned an invalid quote."); } console.log(`Quote: ${quote.cost_credits} credits; available: ${quote.wallet?.available_credits ?? "unknown"} credits`); if (!quote.can_afford || quote.cost_credits > budget) { console.log("Not submitted: the quote is unaffordable or exceeds your credit budget."); return; } if (!values.yes) { if (!process.stdin.isTTY) throw new Error("Use --yes only after choosing a credit budget for non-interactive runs."); const prompt = createInterface({ input: process.stdin, output: process.stdout }); const answer = await prompt.question("Confirm this quote and upload the PDFs? Type yes: "); prompt.close(); if (answer.trim().toLowerCase() !== "yes") { console.log("Not submitted."); return; } } console.log(`Idempotency key: ${idempotencyKey}`); let job; try { job = await confirmQuote(base, key, idempotencyKey, quote.token, files); } catch (error) { console.error("If confirmation lost its response, its outcome may be unknown. Retry only with the same key, token, and ordered files."); throw error; } job = await pollJob(base, key, job); if (job.status !== "succeeded") { throw new Error(`Job ${job.id} ended as ${job.status}; its reserved credits were released.`); } console.log(`Charged: ${job.accepted_credits} credits`); await downloadResults(base, key, job, values.output); } main().catch((error) => { console.error(error.message); process.exitCode = 1; });