#!/usr/bin/env bun /** * Rahunko API multipart upload quickstart. * * Requires a workspace API key with documents:write and jobs:read. Add * exports:read only when using the optional JSON output path. The key is read * from RAHUNKO_API_KEY and is never printed or accepted as a CLI argument. * Production uses the fixed HTTPS origin https://rahunko.com. For explicit * loopback development only, set RAHUNKO_ALLOW_LOCAL_ORIGIN=1 and a loopback * RAHUNKO_API_BASE_URL. Every request rejects redirects and cross-origin URLs. * The sample stops before review acknowledgement. * * Usage: * RAHUNKO_WORKSPACE_ID=ws_... RAHUNKO_API_KEY=rk_... \ * bun public/examples/upload-document.ts ./statement.pdf [result.json] */ import { basename } from "node:path"; const productionOrigin = "https://rahunko.com"; const allowLocal = Bun.env.RAHUNKO_ALLOW_LOCAL_ORIGIN === "1"; const configuredOrigin = Bun.env.RAHUNKO_API_BASE_URL; const origin = (() => { if (!configuredOrigin) return new URL(productionOrigin); const candidate = new URL(configuredOrigin); const loopback = candidate.hostname === "localhost" || candidate.hostname === "127.0.0.1" || candidate.hostname === "[::1]" || candidate.hostname === "::1"; if (!allowLocal || !loopback || (candidate.protocol !== "http:" && candidate.protocol !== "https:")) throw new Error("RAHUNKO_API_BASE_URL is only allowed for explicit loopback development"); return candidate; })(); const workspaceId = Bun.env.RAHUNKO_WORKSPACE_ID ?? ""; if (!/^ws_[A-Za-z0-9]+$/.test(workspaceId)) throw new Error("RAHUNKO_WORKSPACE_ID must match ws_[A-Za-z0-9]+"); const apiKey = Bun.env.RAHUNKO_API_KEY ?? ""; if (!/^rk_[A-Za-z0-9]{40}$/.test(apiKey)) throw new Error("RAHUNKO_API_KEY is missing or malformed"); const filePath = Bun.argv[2]; if (!filePath) throw new Error("Pass a local PDF or supported image path"); const file = Bun.file(filePath); if (!(await file.exists())) throw new Error("Input file does not exist"); const maximumFileBytes = 50 * 1024 * 1024; if (file.size < 1 || file.size > maximumFileBytes) throw new Error("Input file must be between 1 byte and 50 MiB"); const extension = filePath.split(".").at(-1)?.toLowerCase() ?? ""; const extensionMediaTypes: Record = { pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", heic: "image/heic", heif: "image/heif" }; const supportedMediaTypes = ["application/pdf", "image/png", "image/jpeg", "image/webp", "image/heic", "image/heif"]; const declaredMediaType = file.type; const mediaType = supportedMediaTypes.includes(declaredMediaType) ? declaredMediaType : extensionMediaTypes[extension]; if (!mediaType) throw new Error("Input MIME type must be application/pdf or a supported image type"); const sourceKind = mediaType === "application/pdf" ? "pdf" : "image"; const bytes = new Uint8Array(await file.arrayBuffer()); const digest = await crypto.subtle.digest("SHA-256", bytes); const sha256 = Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join(""); const authorization = { Authorization: "Bearer " + apiKey }; const workspacePrefix = "/api/v1/workspaces/" + encodeURIComponent(workspaceId); const safeUrl = (value: string): string => { const resolved = new URL(value, origin); if (resolved.origin !== origin.origin || !resolved.pathname.startsWith(workspacePrefix + "/")) throw new Error("API returned an unsafe cross-origin or cross-workspace URL"); return resolved.toString(); }; const fetchApi = async (input: string, init: RequestInit = {}): Promise => fetch(input, { ...init, redirect: "error", signal: AbortSignal.timeout(60_000) }); const readJson = async (response: Response, allowedStatuses: readonly number[]): Promise => { if (!allowedStatuses.includes(response.status)) throw new Error("Rahunko API request failed with HTTP " + response.status); return (await response.json()) as T; }; const apiPath = (suffix: string): string => safeUrl(workspacePrefix + suffix); const fileName = basename(filePath); if (!fileName || fileName.length > 180) throw new Error("Input filename must be between 1 and 180 characters"); const intentResponse = await fetchApi(apiPath("/uploads"), { method: "POST", headers: { ...authorization, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ batchId: "bun-" + crypto.randomUUID(), fileName, mediaType, byteLength: bytes.byteLength, sha256, sourceKind }) }); const intent = await readJson<{ upload: { id: string; partSize: number; partCount: number; parts: readonly { partNumber: number }[] }; uploadUrl: string; partsUrl: string; completeUrl: string }>(intentResponse, [200, 201]); for (let partNumber = 1; partNumber <= intent.upload.partCount; partNumber += 1) { if (intent.upload.parts.some((part) => part.partNumber === partNumber)) continue; const start = (partNumber - 1) * intent.upload.partSize; const partBytes = bytes.slice(start, Math.min(start + intent.upload.partSize, bytes.byteLength)); const partDigest = await crypto.subtle.digest("SHA-256", partBytes); const partSha256 = Array.from(new Uint8Array(partDigest), (value) => value.toString(16).padStart(2, "0")).join(""); const partResponse = await fetchApi(safeUrl(intent.partsUrl + "/" + partNumber), { method: "PUT", headers: { ...authorization, "Content-Type": "application/octet-stream", "Content-Length": String(partBytes.byteLength), "Idempotency-Key": intent.upload.id + ":" + partNumber, "X-Content-SHA256": partSha256 }, body: partBytes }); await readJson(partResponse, [200, 201]); } await readJson(await fetchApi(safeUrl(intent.completeUrl), { method: "POST", headers: { ...authorization, "Content-Type": "application/json" }, body: "{}" }), [200]); const finalized = await readJson<{ job: { id: string }; document: { id: string } }>(await fetchApi(safeUrl(intent.uploadUrl + "/finalize"), { method: "POST", headers: { ...authorization, "Content-Type": "application/json" }, body: "{}" }), [202]); const jobUrl = apiPath("/jobs/" + encodeURIComponent(finalized.job.id)); const resultUrl = apiPath("/jobs/" + encodeURIComponent(finalized.job.id) + "/result"); const deadline = Date.now() + 15 * 60 * 1000; const ensureTime = (): void => { if (Date.now() >= deadline) throw new Error("Rahunko processing poll timed out after 15 minutes"); }; for (;;) { ensureTime(); const job = await readJson<{ job: { processingStatus: string } }>(await fetchApi(jobUrl, { headers: authorization }), [200]); const status = job.job.processingStatus; if (status === "completed") break; if (status === "failed" || status === "cancelled" || status === "unsupported") throw new Error("Rahunko processing ended in " + status); if (status !== "uploading" && status !== "validating" && status !== "extracting" && status !== "checking") throw new Error("Rahunko returned an unknown processing status"); await Bun.sleep(1000); } for (;;) { ensureTime(); const resultResponse = await fetchApi(resultUrl, { headers: authorization }); const result = await readJson<{ status: "waiting" | "failed" | "ready"; exports?: { json: string }; revision?: number; reviewStatus?: string }>(resultResponse, [200, 409]); if (result.status === "failed") throw new Error("Rahunko result failed; inspect the job issues with the API"); if (result.status === "ready") { const metadata = { documentId: finalized.document.id, revision: result.revision, reviewStatus: result.reviewStatus, exports: result.exports }; console.log(JSON.stringify(metadata, null, 2)); const outputPath = Bun.argv[3]; if (outputPath) { const output = Bun.file(outputPath); if (await output.exists()) throw new Error("Refusing to overwrite existing output file: " + outputPath); if (!result.exports?.json) throw new Error("Ready result did not include a JSON export URL"); const exportResponse = await fetchApi(safeUrl(result.exports.json), { headers: authorization }); if (exportResponse.status !== 200) throw new Error("Rahunko JSON export failed with HTTP " + exportResponse.status); await Bun.write(outputPath, await exportResponse.arrayBuffer()); console.error("json=" + outputPath); } break; } if (result.status !== "waiting" || resultResponse.status !== 409) throw new Error("Rahunko returned an invalid waiting result"); await Bun.sleep(1000); }