@nitida/asset-uploader-web
v0.2.1
Published
nitida browser upload task — resumable multipart to R2 with IndexedDB persistence, so a reload does not restart a 2 GB upload.
Maintainers
Readme
@nitida/asset-uploader-web
Browser uploader for the nitida asset platform.
Single-part and multipart uploads to R2, incrementally SHA-256 hashed with
bounded memory, and IndexedDB persistence so a reload does not restart a 2 GB
upload. Mirrors the API of @nitida/asset-uploader-expo.
Install
npm install @nitida/asset-uploader-web @nitida/asset-client@nitida/asset-client is a peer dependency.
Usage
import { UploadTask } from "@nitida/asset-uploader-web";
const task = new UploadTask({
file,
tenantCode: "acme",
endpoint: "/api/am", // same-origin BFF prefix, or the absolute API URL
presets: ["responsive"], // see below — omitting this is not neutral
});
task.on("progress", ({ ratio, bytesUploaded, totalBytes }) => setPct(ratio));
task.on("done", ({ assetId, deduped }) => console.log(assetId, deduped));
const result = await task.start(); // resumes automatically if state exists
// task.pause() · task.resume() · task.abort()Events: progress, partCompleted, ready, compressed, error, done.
Three things that bite
Omitting presets silently removes the variants. The field is threaded into
/assets/upload-url and /assets/multipart/initiate + /complete. With no
presets the server generates only original — the responsive ladder and the
video AI pipeline just do not happen, and nothing reports an error. The upload
succeeds; the images you expected downstream never exist.
assetId is undefined on two paths, by server contract. On the multipart
path, /assets/multipart/complete enqueues /assets/process and the asset row
is created asynchronously. On single-part videos, /process returns
{ ok, kind: "video", dispatch } with no id at all. Neither is an error. The
sha256 in the result is the universal correlation key: resolve the row with
GET /assets/by-hash/<sha256>. Only the dedup paths, single-part images and
non-media files return an id inline.
Compression is injected, never imported. There is no compress: true — you
hand the compressor function in, so compressorjs and heic2any stay out of
your bundle when you do not use them:
import { compressImage } from "@nitida/asset-compressor-web";
new UploadTask({ file, tenantCode, endpoint, compress: { compressImage } });When compression runs, the compressed bytes are what reach R2 and what the
server-side sha256 describes. The original hash is not lost: the compressed
event carries originalSha256, compressedSha256 and the ratio.
Hashing a 2 GB file without a 2 GB ArrayBuffer
SubtleCrypto.digest() is one-shot: it wants the entire input as a single
BufferSource, which for a multi-GB video means a multi-GB allocation in the JS
heap. WebKit caps a single ArrayBuffer at 4 GB — a hard JSC limit, so every
iOS browser inherits it — and mobile Safari jetsams tabs well below that. This
package instead feeds hash-wasm's incremental createSHA256() with lazy
Blob.slice() chunks: slice() reads no bytes, so peak memory stays at one
8 MiB chunk regardless of file size, and the loop yields between chunks so even
on the main thread nothing janks longer than a single chunk's hash.
Resumability, and what is stored to make it work
Upload state (upload id, completed parts, their ETags, the part size) is written
to IndexedDB as parts land, so start() on a task for the same file picks up
where it stopped — across a reload or a crash, not just a pause(). Persistence
is an optimisation, never a gate: an IndexedDB failure disables resume and logs,
it does not fail an upload whose bytes are moving fine. Pass your own
persistence implementation to store state elsewhere.
The stored session includes presigned R2 PUT URLs, and a presigned URL is a bearer credential. They are persisted because that is what resume-after-reload means; the bounds are worth knowing rather than discovering. They are write-only and scoped to the parts of one upload the user started, the server signs them for 60 minutes and refuses a stale row (the task then asks for fresh URLs), IndexedDB is origin-scoped, and the row is deleted on both complete and abort.
Your authToken is deliberately not among them: it lives only in memory on
the UploadTask instance, UploadSession has no field for it, and a dump of
this database never yields one. A reload loses it and you supply it again.
authToken is for service-to-service calls anyway — in BFF mode leave it out
and cookies ride along with the same-origin request.
License
MIT
