upflowi orchestrates uploads — concurrency, chunking, multipart, retries, progress, pause and resume, cancellation, resumable persistence — for TypeScript and JavaScript, in the browser and in Node.js, without an opinion on what your interface looks like.
pnpm add @upflowi/core @upflowi/transport-fetch @upflowi/provider-s3A single-cloud SDK is fast to start and permanent to leave — switching storage means rewriting the upload path. A full-service uploader ships a UI you didn't ask for and now have to theme, translate, and maintain. upflowi keeps the orchestration — queueing, chunking, retry, progress, resumable state — identical across providers, and asks nothing of your interface.
Headless and provider-agnostic aren't claims — they're a consequence of keeping these four concerns apart. Each layer only ever talks to the one below it.
Decides what to upload, in what order, and tracks status.
Decides how many operations run at once — global, per-file, per-chunk.
Moves bytes — HTTP mechanics, progress events, headers, abort wiring.
Defines which operations exist on a backend and maps them onto transport calls.
A new transport or provider never touches scheduler.ts or queue.ts — if it does, the abstraction leaked.
The same boundary covers access control: whether an upload ends up public or private is your backend's call when it signs the URL — upflowi has no ACL concept, and it never serves a file back.
A pnpm workspace. Install @upflowi/core plus one transport; add a provider only for multipart transfers.
Multipart upload to S3, driven entirely by presigned URLs your backend issues.
import { createUploader } from "@upflowi/core";
import { createFetchTransport } from "@upflowi/transport-fetch";
import { createS3Provider } from "@upflowi/provider-s3";
const uploader = createUploader({
concurrency: 3,
transport: createFetchTransport(),
provider: createS3Provider({
getPresignedUrl: (operation) => backendClient.getS3PresignedUrl(operation),
}),
});
const upload = uploader.add({ source: mySource });
upload.on("progress", (progress) => console.log(`${progress.percent.toFixed(1)}%`));
uploader.start();No provider, one request — a destination URL and a source.
import { createUploader } from "@upflowi/core";
import { createFetchTransport } from "@upflowi/transport-fetch";
const uploader = createUploader({ transport: createFetchTransport() });
const upload = uploader.add({
source: {
fileId: "avatar.png",
size: file.size,
read: async () => file, // Blob, ArrayBuffer, ArrayBufferView, or string
},
options: { url: "https://your-backend.example.com/uploads/avatar.png" },
});
upload.on("completed", ({ result }) => console.log("done:", result));
uploader.start();Every error extends UploadError — branch on it with instanceof instead of parsing strings.
startedUploadEmitted by every Upload handle.
progressUploadEmitted by every Upload handle.
pausedUploadEmitted by every Upload handle.
resumedUploadEmitted by every Upload handle.
retryUploadEmitted by every Upload handle.
completedUploadEmitted by every Upload handle.
failedUploadEmitted by every Upload handle.
cancelledUploadEmitted by every Upload handle.
queuedUploaderEmitted by the Uploader.
allCompletedUploaderEmitted by the Uploader.
UploadErrorextends ErrorThe base every other error extends. Branch on any of these with instanceof.
NetworkErrorextends UploadErrorNo response was received at all.
HttpErrorextends UploadErrorA non-2xx response came back.
AbortErrorextends UploadErrorThe AbortSignal fired. retryable is always false.
RetryExhaustedErrorextends UploadErrorEvery configured attempt failed. cause is the last underlying error.
UploadValidationErrorextends UploadErrorBad input, caught before any network call.
ProviderErrorextends UploadErrorS3 / R2 / your backend rejected the operation.