@holdthis/upload
v0.1.0
Published
Browser upload SDK for holdthis media infrastructure
Maintainers
Readme
@holdthis/upload
Browser upload SDK for holdthis media infrastructure. Uploads files directly from the browser to B2 via presigned URLs -- zero bytes through your server. Multipart, concurrent, with progress tracking, pause/resume, retry, and offline detection.
- Tiny footprint (ships unminified source; your bundler tree-shakes and minifies)
- Zero dependencies
- Works with any frontend framework
- TypeScript declarations included
Installation
npm install @holdthis/uploadQuick start
Proxy mode (recommended)
Your backend proxies to the holdthis API. The SDK handles init, upload, and finalization automatically.
import { createUpload } from "@holdthis/upload";
const upload = createUpload({
file: fileInput.files[0],
initUrl: "/api/upload/init",
doneUrl: "/api/upload/done",
headers: { "X-CSRF-Token": csrfToken },
onProgress({ percent, bytesUploaded, bytesTotal }) {
progressBar.style.width = `${percent}%`;
},
onSuccess({ uploadId, parts }) {
console.log("Upload complete:", uploadId);
},
onError(err) {
console.error("Upload failed:", err.message);
},
});
upload.start();Your backend's /api/upload/init proxies to the holdthis
POST /api/v1/upload/init endpoint (adding your API key), and returns the
response ({ uploadId, parts: [{ start, end, url }] }). Same for
/api/upload/done.
Direct mode
If you fetch presigned URLs yourself, pass them directly.
import { createUpload } from "@holdthis/upload";
const file = fileInput.files[0];
// Your backend fetches these from holdthis /api/v1/upload/init.
const { uploadId, parts } = await fetchPresignedUrls(file);
const upload = createUpload({
file,
uploadId,
parts, // [{ start, end, url }, ...]
onProgress({ percent }) {
console.log(`${percent}%`);
},
onSuccess({ uploadId, parts }) {
// POST parts (with ETags) to your backend to finalize.
finalizeUpload(uploadId, parts);
},
onError(err) {
console.error(err);
},
});
upload.start();API
createUpload(options): Upload
Creates an upload instance. Throws if the file fails validation or options
are invalid. The upload does not start until upload.start() is called.
Options (proxy mode):
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| file | File | Yes | The file to upload |
| initUrl | string | Yes | Your backend's init endpoint |
| doneUrl | string | Yes | Your backend's done endpoint |
| headers | Record<string, string> | No | Extra headers for init/done (CSRF, auth). Content-Type is always set to application/json by the SDK |
Options (direct mode):
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| file | File | Yes | The file to upload |
| uploadId | string | Yes | Upload ID from your backend |
| parts | { start, end, url }[] | Yes | Presigned URLs with byte ranges. start and end are byte offsets forming a half-open interval [start, end) -- end is exclusive |
Shared options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| concurrency | number | 3 | Concurrent part uploads |
| maxAttempts | number | 5 | Max attempts per part |
| retryBaseDelay | number | 1000 | Initial retry backoff (ms) |
| maxSize | number | - | Max file size in bytes |
| allowedTypes | string[] | - | Allowed MIME patterns ("audio/*", "video/mp4") |
Callbacks:
| Callback | Payload | Description |
|----------|---------|-------------|
| onProgress | { bytesUploaded, bytesTotal, percent } | Aggregate upload progress |
| onPartComplete | { partNumber, etag } | A part finished uploading |
| onSuccess | { uploadId, parts: [{ partNumber, etag }] } | All parts uploaded (and done called in proxy mode) |
| onError | Error | Fatal error. Check err.name === "AbortError" for user-initiated abort. Always provide this -- start() does not return a promise, so without onError failures surface as unhandled promise rejections |
| onOffline | - | Network lost, upload auto-paused |
| onOnline | - | Network restored, upload auto-resumed |
Upload
| Method | Description |
|--------|-------------|
| start() | Begin uploading. No-op if already started |
| pause() | Cancel in-flight requests. Completed parts are preserved |
| resume() | Restart from where pause left off |
| abort() | Cancel permanently. Fires onError with AbortError |
validateFile(file, options?): Error | null
Validates a file against size and type constraints without creating an
upload. Returns null if valid, an Error if not.
import { validateFile } from "@holdthis/upload";
const err = validateFile(file, {
maxSize: 500 * 1024 * 1024,
allowedTypes: ["audio/*", "video/*"],
});
if (err) {
alert(err.message);
}Pause and resume
pauseButton.onclick = () => upload.pause();
resumeButton.onclick = () => upload.resume();Completed parts are preserved across pause/resume -- only incomplete parts are restarted.
Network awareness
The SDK listens for online/offline events and automatically pauses when
the network drops and resumes when it returns. The onOffline and onOnline
callbacks fire so you can update your UI.
Manual pauses are respected -- an online event will not auto-resume an
upload that was paused by the user.
Retry
Failed parts retry automatically with exponential backoff (1s, 2s, 4s, 8s cap). Network errors, 5xx, 408, and 429 responses are retried. Other 4xx errors fail immediately.
Integration guide
Backend proxy pattern
Your platform backend sits between the browser and holdthis. The browser never sees your holdthis API key.
Browser Your Backend holdthis
| | |
|-- POST /api/upload/init -->|-- POST /api/v1/upload/init -->|
|<-- { uploadId, parts } ----|<-- { uploadId, parts } -------|
| | |
|-- PUT parts directly to B2 (presigned URLs) ------------->|
| | |
|-- POST /api/upload/done -->|-- POST /api/v1/upload/done -->|
|<-- success ----------------|<-- success -------------------|Your init endpoint receives the file metadata from the SDK:
{ "filename": "episode.mp3", "size": 52428800, "contentType": "audio/mpeg" }Forward it to holdthis with your API key, return the response as-is.
CORS
B2 buckets must allow browser PUT requests. Required CORS configuration:
- Allowed origins:
*(or your specific origins) - Allowed methods:
PUT - Allowed headers:
Content-Type,X-Request-ID - Exposed headers:
ETag,Last-Modified
Without exposing ETag, the browser cannot read ETags and the upload cannot
be finalized. Last-Modified is optional but useful for post-upload verification.
License
Proprietary. Copyright holdthis.
