A Vue 3 upload bar that follows the file all the way to the bucket
axios onUploadProgress is easy. Making the percentage honest means uploading straight to a private bucket — the sign endpoint, the component, and the two slot shapes.
onUploadProgress in axios reports bytes leaving the browser, so the percentage is only honest if those bytes are going to the bucket rather than to your own Node process. With Infrai that’s a choice you get to make: POST /v1/storage/bucket/set_cors/{bucket} writes the browser rules on the bucket and POST /v1/storage/object/presign/{bucket}/{key} with op: "put" issues a narrow upload slot, so the file can go straight from the file input to private storage while your server only ever handles a signature request.
This walkthrough builds that: a Fastify route that signs, a Vue 3 component that uploads, and a bar that means what it says.
One-time bucket setup
The rules are replace-the-list, and origins are matched literally — include your dev server or you’ll spend the afternoon on it:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_cors/kb-submissions-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"allowed_origins":["https://app.example.com","http://localhost:5173"],"allowed_methods":["PUT","POST","GET","HEAD"],"allowed_headers":["content-type"],"expose_headers":["ETag"],"max_age_seconds":3600}]}'
Read it back with GET /v1/storage/bucket/get/{bucket} and you’ll see the same array under cors_rules. That’s the deploy-time half done.
The sign endpoint
Your server’s only job is to decide who may write where. It never sees the file:
import Fastify from "fastify";
import { randomUUID } from "node:crypto";
const app = Fastify();
const API = "https://api.infrai.cc";
const BUCKET = "kb-submissions-0726";
const ALLOWED = new Set(["application/pdf", "image/png", "image/jpeg"]);
const MAX_BYTES = 25 * 1024 * 1024;
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
app.post("/api/uploads/sign", async (request, reply) => {
const { contentType, sizeBytes } = request.body ?? {};
if (!ALLOWED.has(contentType)) return reply.code(415).send({ error: "unsupported file type" });
if (!Number.isInteger(sizeBytes) || sizeBytes > MAX_BYTES) {
return reply.code(413).send({ error: "file too large" });
}
const objectKey = `inbox/${request.headers["x-user-id"]}/${randomUUID()}`;
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
op: "put",
expires_seconds: 600,
content_type: contentType,
max_bytes: MAX_BYTES,
}),
});
const out = await res.json();
if (!res.ok || out.ok === false) {
request.log.error({ status: res.status, code: out?.error?.code }, "presign failed");
return reply.code(502).send({ error: "could not issue an upload slot" });
}
return reply.send({ objectKey, slot: out.data });
});
await app.listen({ port: 3000 });
Four constraints ride along with that signature: one key, ten minutes, one content type, one size cap. The cap is enforced by the upload policy itself, so a client that lies about sizeBytes still can’t put 400 MB through the slot.
The component
<script setup>
import { ref } from "vue";
import axios from "axios";
const percent = ref(0);
const phase = ref("idle");
const error = ref("");
let controller = null;
async function onPick(event) {
const file = event.target.files?.[0];
if (!file) return;
controller = new AbortController();
percent.value = 0;
phase.value = "signing";
error.value = "";
try {
const { data } = await axios.post("/api/uploads/sign", {
contentType: file.type,
sizeBytes: file.size,
});
const slot = data.slot;
phase.value = "uploading";
const onUploadProgress = (e) => {
if (e.total) percent.value = Math.round((e.loaded / e.total) * 100);
};
if (slot.fields) {
const form = new FormData();
for (const [name, value] of Object.entries(slot.fields)) form.append(name, value);
form.append("file", file);
await axios.post(slot.url, form, { signal: controller.signal, onUploadProgress });
} else {
await axios.put(slot.url, file, {
signal: controller.signal,
onUploadProgress,
headers: { "Content-Type": file.type },
});
}
await axios.post("/api/uploads/confirm", { objectKey: data.objectKey });
phase.value = "stored";
} catch (err) {
error.value = axios.isCancel(err) ? "cancelled" : (err.response?.data?.error ?? err.message);
phase.value = "failed";
}
}
function cancel() {
controller?.abort();
}
</script>
<template>
<div class="uploader">
<input type="file" accept="application/pdf,image/png,image/jpeg" @change="onPick" />
<progress :value="percent" max="100" />
<span>{{ phase }}</span>
<button v-if="phase === 'uploading'" @click="cancel">Cancel</button>
<p v-if="error" class="err">{{ error }}</p>
</div>
</template>
The branch on slot.fields is the part to keep. Ask for max_bytes and the presign response comes back as a browser policy form — method: "POST", a url pointing at the bucket root, and a fields object you copy into FormData before appending the file. Leave max_bytes out and you get a signed PUT URL instead. Both report progress identically to axios, because in both cases the browser is streaming the file to storage.
One trap, and it’s the one that costs an afternoon: when you sign with content_type, that header is part of the signature. Send application/octet-stream against a slot signed for image/png and storage answers 403 with SignatureDoesNotMatch in the body — not a CORS error, despite what the network tab suggests. Pass file.type through unchanged.
Confirming the upload
The browser saying “done” isn’t your record. Have /api/uploads/confirm verify with a free metadata read before it writes a database row:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-submissions-0726/inbox/u_2317/contract.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "inbox/u_2317/contract.pdf",
"size_bytes": 918273,
"etag": "6446a98080f5e51ab7f0abc0e8eda635",
"content_type": "application/pdf",
"last_modified": "2026-07-27T12:14:02Z"
}
}
Branch on found rather than the status code — a missing key is a 200 with found: false, not a 404. Executable content types are refused at the storage layer with STORAGE_CONTENT_TYPE_BLOCKED, so keeping your own allowlist in the sign endpoint means the user gets a clean 415 from you instead of a confusing rejection at upload time.
And the moment the row is written, the rest of the pipeline is already on the same key: POST /v1/queue/publish to schedule processing, POST /v1/image/resize for the preview, POST /v1/email/send to tell the reviewer. No second vendor for the job runner, no third for the mail, and one usage view covering all of it.
Three shapes, and when each one fits
| Shape | Progress covers | Server memory | Where it fits |
|---|---|---|---|
| Browser → presigned slot | The real transfer | None | The default here; anything above a few MB |
| Browser → your API → storage | Browser to your server only | Whole file per request | Small files you must scan or transform first |
| Browser → presigned parts | Per part, resumable | None | Files past ~100 MB, if you build the chunking |
If you’re stuck on the middle row for policy reasons, the honest fix for the bar is to map the browser-to-server leg onto 0–90% and reserve the last ten points for the hop the browser can’t see. Users read a bar sitting at 90% for two seconds as “nearly done”; they read one sitting at 100% for two seconds as broken.
Limitations worth knowing
The catch is that a presigned slot is a bearer token: whoever holds it can write to that one key until it expires, and there’s no revoke, so short TTLs and per-user keys are the controls that matter. Resumability is the other limitation — there’s no client library here that will restart a half-finished 200 MB upload, so a flaky mobile connection starts over unless you implement multipart chunking yourself. If resumable browser uploads are a product requirement rather than a nice-to-have, Cloudflare R2 behind a library that already does it is a fair pick, and Amazon S3’s managed uploader is the same argument in a different accent.
What it costs
Presign, set_cors, head and list are free and rate-limited, so the whole flow above is billed on one event. Verified 27 July 2026, a stored object costs $0.0001 per write call regardless of its size, and reading one back through the API is metered by volume at $0.104 per GB. Bytes at rest are metered separately and will dominate the invoice long before call fees do:
curl -sS -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Rates drift down over time, so treat those as a ceiling and check GET /v1/account/usage once real traffic is flowing.