uplifty
v2.0.1
Published
Direct-to-storage file uploads from the browser via presigned URLs. Zero dependencies, works with S3, Cloudflare R2, DigitalOcean Spaces, MinIO, Backblaze, Wasabi, Tigris and Supabase.
Maintainers
Readme
Uplifty
Direct-to-storage file uploads from the browser. Your server mints a signed ticket. The browser sends the bytes straight to storage. Your credentials never leave your server, and the files never touch it.
Quick start · Documentation · Connect an S3 bucket · Architecture · API
The problem
Uploading a file to cloud storage from a web app has exactly one safe shape, and every team rebuilds it from scratch:
flowchart LR
subgraph bad ["❌ Proxying through your server"]
direction LR
B1[Browser] -->|100 MB| S1[Your server]
S1 -->|100 MB again| T1[(Storage)]
end
subgraph good ["✅ Direct to storage"]
direction LR
B2[Browser] -.->|"a few bytes<br/>(ask for a ticket)"| S2[Your server]
B2 -->|100 MB, once| T2[(Storage)]
endProxying burns your bandwidth, your memory, and your request timeout on bytes you only forward. Direct upload avoids all three — but doing it safely means presigned URLs, SigV4 signing, CORS, progress tracking, retries, and cancellation. That is a week of work per project, and the failure mode of getting it wrong is leaked credentials.
Uplifty is that week, packaged.
How it works
sequenceDiagram
autonumber
participant B as Browser<br/>(uplifty)
participant S as Your server<br/>(uplifty/server)
participant T as Storage<br/>(S3, R2, Supabase…)
B->>S: POST /api/upload<br/>{ fileName, contentType, size }
Note over S: Authenticate the user.<br/>Enforce size + type limits.<br/>Decide the object key.
S->>S: Sign a presigned URL<br/>(credentials stay here)
S-->>B: UploadTicket<br/>{ url, headers, key, expiresAt }
B->>T: PUT the file bytes ──────────►
Note over B,T: onProgress fires as bytes leave.<br/>Your server is not in this path.
T-->>B: 200 OK + ETag
B-->>S: (your code) save `key` to the databaseThe browser half never learns which provider is on the other end. It receives a URL, some headers, and a method — it PUTs the bytes and reports progress. That is the whole contract, and it is why swapping S3 for R2 is a server-side config change with no client rebuild.
Why the split matters
The package ships as three separate entry points, and that boundary is enforced in CI:
flowchart TB
subgraph browser ["🌐 Browser bundle"]
C["<b>uplifty</b><br/>Uplifty client<br/><i>3.9 kB gzipped</i>"]
R["<b>uplifty/react</b><br/>useUpload hook<br/><i>4.7 kB gzipped</i>"]
R --> C
end
subgraph server ["🔒 Server / edge only"]
SV["<b>uplifty/server</b><br/>S3 + SigV4 signer<br/><i>5.5 kB gzipped</i>"]
K["🔑 accessKeyId<br/>🔑 secretAccessKey"]
SV -.holds.-> K
end
C -.->|"UploadTicket (JSON over HTTP)<br/>no import, no coupling"| SV
style browser fill:#e8f4ff,stroke:#4a90d9
style server fill:#fff4e6,stroke:#d99b4a
style K fill:#ffe0e0,stroke:#d94a4auplifty and uplifty/server never import each other. They communicate only through the UploadTicket JSON contract. CI greps the built browser bundle for signing code and fails the build if any appears — so the credential leak that this architecture prevents cannot be reintroduced by an accidental import.
Upgrading from 1.x? Version 1.0.0 accepted your AWS keys in browser code, which exposed them to every visitor. See MIGRATION.md — rotate those credentials first.
Install
npm install upliftyZero runtime dependencies. Works in Node 20+, Bun, Deno, Cloudflare Workers, Vercel Edge, and every modern browser. React is an optional peer dependency, needed only for uplifty/react.
Quick start
1. Mint tickets on your server
// app/api/upload/route.ts (Next.js App Router)
import { S3 } from 'uplifty/server';
const storage = new S3({
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: 'us-east-1',
bucket: 'my-app-uploads',
maxSize: 10 * 1024 * 1024,
allowedContentTypes: ['image/*', 'application/pdf'],
});
export async function POST(request: Request) {
const session = await auth(); // ← your auth. This is the real access control.
if (!session) return new Response('Unauthorized', { status: 401 });
const { fileName, contentType, size } = await request.json();
const ticket = await storage.createUploadTicket({
fileName,
contentType,
size,
// Scopes the key to this user. Note it REPLACES any instance-level
// `prefix` rather than nesting inside it.
prefix: `users/${session.userId}/`,
});
return Response.json(ticket);
}2. Upload from the browser
import { Uplifty } from 'uplifty';
const uplifty = new Uplifty({ getTicket: '/api/upload' });
const result = await uplifty.upload(file, {
onProgress: ({ percent }) => setProgress(percent),
});
console.log(result.key, result.url);3. Or use the React hook
import { useUpload } from 'uplifty/react';
export function UploadButton() {
const { upload, progress, isUploading, error, abort } = useUpload({
getTicket: '/api/upload',
});
return (
<>
<input
type="file"
disabled={isUploading}
onChange={e => e.target.files?.[0] && upload(e.target.files[0])}
/>
{isUploading && (
<>
<progress value={progress} max={100} />
<button onClick={abort}>Cancel</button>
</>
)}
{error && <p role="alert">{error.message}</p>}
</>
);
}That is the whole integration. Connecting a real S3 bucket — the IAM policy and CORS rule — takes about five minutes.
What you get
| | |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- |
| Never proxies bytes | Files go browser → storage. Your server handles a few hundred bytes of JSON per upload. |
| Credentials stay put | Secret keys exist only in uplifty/server, which is unreachable from the browser entry point. |
| Real enforcement | content-type and content-length are bound into the signature — the provider rejects mismatches, not you. |
| Progress and cancel | Byte-accurate progress via XHR, AbortSignal cancellation, per-file and aggregate. |
| Retries | Exponential backoff on transient failures, with automatic ticket refresh when one expires mid-retry. |
| Concurrency | uploadAll runs a bounded worker pool and stops dispatching on the first failure. |
| Eight backends | One signer, eight providers — S3, R2, Spaces, MinIO, Backblaze, Wasabi, Tigris, Supabase. |
| Genuinely small | 3.9 kB gzipped in the browser, zero dependencies, tree-shakeable, ESM + CJS. |
| Runs anywhere | WebCrypto only — Node, Bun, Deno, Workers, Edge. No aws-sdk, no Node built-ins. |
Providers
Every backend is the same S3 class with different configuration. Presets exist so you do not have to remember each vendor's endpoint format:
import { S3, R2, Spaces, MinIO, Backblaze, Wasabi, Tigris, Supabase } from 'uplifty/server';
const r2 = R2({ accountId: '...', accessKeyId, secretAccessKey, bucket: 'uploads' });
const supabase = Supabase({ projectRef: 'abcd', region: 'us-east-1', accessKeyId, secretAccessKey, bucket: 'avatars' });Full configuration for each — including which credentials to generate and where — is in docs/providers.md.
Documentation
| Guide | What it covers | | -------------------------------------------------- | --------------------------------------------------------------------------- | | Getting started | A complete working integration, from install to first uploaded file. | | Connect an S3 bucket | Bucket, IAM user, least-privilege policy, CORS, and verification. Start here. | | Providers | Setup for all eight backends, with the gotchas each one has. | | API reference | Every option, method, type, and error code. | | Architecture | Why it is built this way, and how the signing actually works. | | Recipes | Image galleries, drag-and-drop, private files, post-upload processing. | | Troubleshooting | Every error this library throws, and what actually causes it. | | Security model | Threat model, what a ticket does and does not authorise, reporting a bug. | | Migrating from 1.x | What changed and why, plus the credential rotation you must do. |
Runnable examples live in examples/ — vanilla JS, Express, and Next.js.
Security model in one paragraph
A ticket is a capability: it authorises one upload, of one declared size and content type, to one object key, for a short window. Your server decides all four before signing, which is where your real access control belongs. Client-side maxSize and allowedContentTypes are UX conveniences — anyone can bypass them. The server-side equivalents are the enforcement, because they are bound into the signature and checked by the storage provider itself. Full detail in SECURITY.md.
Runtime support
| Runtime | Browser entry | Server entry | | -------------------------- | ------------- | ------------------------ | | Modern browsers | ✅ | — | | Node 20+ | — | ✅ | | Bun / Deno | — | ✅ | | Cloudflare Workers | — | ✅ | | Vercel / Netlify Edge | — | ✅ | | React Native | ⚠️ untested | — |
The server half needs only crypto.subtle and fetch, both of which are standard in every runtime above.
Roadmap
Google Cloud Storage, Azure Blob, Cloudflare Images, native Supabase, multipart uploads for very large files, and framework adapters beyond React are tracked as open issues with the research already done. Contributions welcome — start with CONTRIBUTING.md.
Contributing
git clone https://github.com/nitin-1926/UpLifty.git
cd UpLifty && npm install
npm run check # typecheck + lint + tests
npm run smoke # build, pack, and verify the real tarballSee CONTRIBUTING.md for the full workflow and CODE_OF_CONDUCT.md for community expectations.
License
MIT © Nitin Gupta
