@vivsh1999/upupload
v0.7.1
Published
Client-first, multi-stage file uploader/processor with safe fallback-to-original behavior
Readme
@vivsh1999/upupload
Client-first, multi-stage file uploader/processor with a plugin architecture for custom processing.
- Background Web Worker Threading — Offload intensive image scaling/compression entirely to a Web Worker via
useWorker: true - Progressive Memory Garbage Collection — Auto-purges file buffers and revokes object URLs on successful uploads to keep RAM clean
- Pre-built Upload Adapters — Zero-dependency, tree-shakable standard HTTP & S3/R2 presigned URL upload adapters
- Pipeline engine handles validation, original passthrough, video posters, and safe fallback
- Plugin system — every file-type-specific processor is a separate, tree-shakeable plugin
- Ships built-in plugins:
rawToJpeg(RAW/HEIC/TIFF),jpegCompressor(compress/thumbnail),videoPoster - Zero-cost imports — plugins are tree-shaken at the bundler level; pay only for what you use
- No auto-installed heavy deps — plugin dependencies are never installed unless you add them
- TypeScript-native, fully typed
Who Is This For?
| You want to… | Start here | | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Use built-in plugins (or none at all) to process images/video in your app | Quick Start ↓ | | Write your own custom plugin for a specific file type or processing step | Custom Plugins ↓ & docs/plugins.md | | Publish a plugin for the community (open-source extension) | Publishing Plugins ↓ & docs/plugins.md | | Contribute to the repo itself — fix bugs, add features, improve docs | CONTRIBUTING.md |
Agent Skills
Install the UpUpload agent skill for AI-powered guidance on plugin configuration, React hook usage, custom plugin development, and more:
npx skills add vivsh1999/upuploadWorks with OpenCode, Claude Code, Cursor, Codex, and 50+ other coding agents.
Installation
npm add @vivsh1999/upuploadThe package itself has zero image-processing dependencies on first install.
Plugin dependencies (install only what you need)
# For JPEG/PNG/WebP compression
npm add browser-image-compression
# For RAW camera files (CR3, DNG, NEF, ARW…)
npm add libraw-wasm
# Optional: for HEIC/HEIF / TIFF decode via the raw-to-jpeg plugin
npm add heic-decode heic2any utifEntry Points
| Path | Environment | Contents | Bundle cost |
| --------------------------------------------- | ----------- | -------------------------------------------------------- | ----------- |
| @vivsh1999/upupload | Browser | Re-exports core (pipeline engine, types, result helpers) | — |
| @vivsh1999/upupload/browser | Browser | Pipeline, allowlist, audio/canvas utils, plugins | 8 kB |
| @vivsh1999/upupload/core | Universal | Generic pipeline engine, types, result helpers | 1 kB |
| @vivsh1999/upupload/react | Browser | useFileUpload React hook | 60 kB |
| @vivsh1999/upupload/adapters | Browser | Pre-built fetch & S3/R2 upload adapters | 5 kB |
| @vivsh1999/upupload/server | Node | Server entry (minimal) | < 1 kB |
| @vivsh1999/upupload/plugins | Browser | Barrel re-export of all plugins | N/A |
| @vivsh1999/upupload/plugins/jpeg-compressor | Browser | JPEG/PNG/WebP compressor plugin | +4 kB |
| @vivsh1999/upupload/plugins/raw-to-jpeg | Browser | RAW/HEIC/TIFF decoder plugin | +12 kB |
| @vivsh1999/upupload/plugins/video-poster | Browser | Video poster frame plugin | +6 kB |
| @vivsh1999/upupload/plugins/testing | Browser | Plugin test utilities | +1 kB |
| @vivsh1999/upupload/preset | Browser | Zero-config upload() with auto-detected plugins | +13 kB |
Only the specific plugin path you import is added to your bundle.
Quick Start
React (with built-in plugins)
import { useFileUpload } from "@vivsh1999/upupload/react";
import { jpegCompressor } from "@vivsh1999/upupload/plugins";
function Uploader() {
const {
getDropTargetProps,
getFileInputProps,
queue,
startUpload,
cancelUpload,
isDragOver,
isBusy,
} = useFileUpload({
plugins: [jpegCompressor.with({ quality: 80, maxSizeMB: 1 })],
uploadAdapter: async (artifact, { onProgress, fileId, totalArtifacts, artifactIndex }) => {
for (let pct = 0; pct <= 100; pct += 10) {
await new Promise((r) => setTimeout(r, 10));
onProgress(pct);
}
await fetch("/api/upload", { method: "POST", body: artifact.blob });
},
});
return (
<div {...getDropTargetProps()} style={{ border: isDragOver ? "2px dashed blue" : "" }}>
<input {...getFileInputProps()} />
{queue.map((item) => (
<div key={item.id}>
{!item.needsReselect && <img src={item.previewUrl} alt="" width={40} />}
{item.name} — {item.status} ({item.progress}%)
{item.status === "error" && !item.needsReselect && (
<button onClick={() => cancelUpload(item.id)}>Cancel</button>
)}
</div>
))}
<button onClick={() => startUpload()} disabled={isBusy}>
Upload
</button>
</div>
);
}React (no plugins — validation + original passthrough only)
import { useFileUpload } from "@vivsh1999/upupload/react";
function Uploader() {
const { getDropTargetProps, getFileInputProps, queue, startUpload } =
useFileUpload();
// No plugins passed — files pass through validation only.
// Queue items will have 1 artifact: variant "original".
return (/* … */);
}Vanilla JS (with built-in plugins)
import { runDefaultBrowserPipeline } from "@vivsh1999/upupload/browser";
import { jpegCompressor } from "@vivsh1999/upupload/plugins";
const result = await runDefaultBrowserPipeline(source, opts, {
plugins: [jpegCompressor.with({ quality: 80, maxSizeMB: 1 })],
});Vanilla JS (no plugins)
import { runDefaultBrowserPipeline } from "@vivsh1999/upupload/browser";
const result = await runDefaultBrowserPipeline({ file, name: file.name, type: file.type }, {});
// result.artifacts has 1 item: variant "original"Preset (zero-config)
import { upload } from "@vivsh1999/upupload/preset";
const result = await upload(file, { quality: 80 });React Hook Options
The hook accepts a UseFileUploadOptions<TMeta, TPreload> object. Key options:
| Option | Description |
| ---------------------------------- | ------------------------------------------------------------------------- |
| plugins | Processing plugins to apply |
| pipeline | PipelineDef[] for per-type routing |
| pipelineConfig | Pass { logLevel: "debug" } for verbose console output |
| uploadAdapter | Function that receives each artifact and its helpers |
| tuning.maxConcurrency | Pipeline parallelism (default: CPU count, capped at 4) |
| tuning.maxUploadConcurrency | Upload adapter parallelism (defaults to maxConcurrency) |
| maxQueuedUploads | Backpressure limit for "uploading" state |
| maxFileSize | Reject files over N bytes |
| maxTotalBatchSize | Reject if total batch exceeds N bytes |
| maxNumberOfFiles | Cap on total queue items |
| persistence | "memory" or "indexeddb" (survives page reload) |
| storageKeyPrefix | Isolate IndexedDB database name |
| retryMode | "pipeline" (default) or "adapter-only" (skip re-compression on retry) |
| autoPreventTabClose | Prevent tab close during processing |
| autoPauseOnOffline | Auto-pause on network disconnect |
| autoWakeLock | Prevent screen sleep during upload |
| getMeta | Attach custom metadata (TMeta) to each queue item |
| getPipelineContextMeta | Inject values into every file's pipeline shared context |
| onBeforeStart | Batch pre-processing hook, returns TPreload for adapter |
| onFileProcessed | Fires after pipeline, before upload |
| onFileComplete | Fires after pipeline + upload complete |
| onBatchComplete | Cumulated stats when batch finishes |
| onBatchProgress | Live progress during batch processing/uploads |
| onInfo / onWarning / onError | Structured logging and error callbacks |
Queue items are a discriminated union — when restored from IndexedDB after page reload, needsReselect: true and file is unavailable. Check item.needsReselect before accessing item.file.
Full reference: docs/react.md
uploadAdapter
The uploadAdapter replaces manual onFileComplete iteration and provides helper fields:
useFileUpload<{ sessionId: string }, { token: string }>({
plugins: [jpegCompressor.with({ quality: 80 })],
onBeforeStart: async (files) => {
const res = await fetch("/api/bulk-init", { method: "POST" });
return { token: await res.text() };
},
uploadAdapter: async (
artifact,
{ onProgress, signal, fileId, totalArtifacts, artifactIndex, batch },
) => {
// artifact: { variant, blob, filename, filetype }
// onProgress(0-100) — updates the queue item's progress
// signal — honour cancellation
// fileId, totalArtifacts, artifactIndex — per-file context
// batch.files, batch.batchId, batch.preload.token — batch context
for (let pct = 0; pct <= 100; pct += 10) {
await new Promise((r) => setTimeout(r, 10));
if (signal?.aborted) return;
onProgress(pct);
}
await fetch("/api/upload", { method: "POST", body: artifact.blob });
},
});For custom upload without the hook, use the core result helpers:
import { upload } from "@vivsh1999/upupload/preset";
const result = await upload(file, { quality: 80 });
for (const artifact of result.artifacts) {
await fetch("/api/upload", {
method: "POST",
body: artifact.file,
headers: { "Content-Type": artifact.filetype },
});
}File Processing Flow (React Hook)
When you call startUpload(), files go through three throttle-controlled stages:
Input → queue (idle)
│
▼
┌──────────────────────────────────────────┐
│ 1. Pipeline Processing (maxConcurrency) │ ← compression, transcoding
│ • Each file acquires a semaphore slot │ (0 – pipelineEndProgress%)
│ • Progress derived from completed │
│ • stages / total stages × 90 │
│ • Multiple files processed in parallel│
│ • Plugins run sequentially per file │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 2. Upload Adapter (per file, sequential) │ ← your adapter sends artifacts
│ • Called once per artifact │ (pipelineEndProgress – 99%)
│ • All artifacts of a file are sent │
│ sequentially (one at a time) │
│ • Adapter receives batch context │
│ via `helpers.batch` │
└──────────────────────────────────────────┘
│
▼
File marked "complete" → onFileComplete firesThrottles (three independent controls)
| Setting | Controls | Default |
| ----------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------- |
| tuning.maxConcurrency | How many files run the pipeline simultaneously | navigator.hardwareConcurrency (capped at 4) |
| tuning.maxUploadConcurrency | How many files upload artifacts simultaneously | Same as maxConcurrency |
| maxQueuedUploads | Backpressure: how many files can be in "uploading" state at once before new files pause | Unlimited |
Important: maxConcurrency and maxUploadConcurrency are independent semaphores.
- Four files could be processing pipelines while two others are uploading artifacts.
maxQueuedUploadsis a global ceiling on the number of files in"uploading"state. When hit, files that have finished processing will not start uploading until a slot frees up.
retryUpload lifecycle
retryUpload(fileId) re-runs only the upload adapter — the pipeline
(compression, transcoding, etc.) is not re-executed. The existing
artifacts from the original processing are re-used. This means:
- The adapter must be idempotent: it may receive the same artifact blob
across multiple
retryUploadcalls. - If the pipeline failed (no artifacts),
retryUploadreturns early (no-op). Callretry(fileId)instead to reset the file to"idle"and re-process it through the full pipeline.
retryMode
Set retryMode: "adapter-only" on the hook so retry(fileId) skips
re-compression and re-runs only the upload adapter when artifacts exist.
Falls back to full re-processing if no artifacts are available.
Custom Plugins
Write your own plugin to handle file types or processing that the built-in plugins don't cover.
Minimal Example
import { Plugin } from "@vivsh1999/upupload/plugins";
import { artifact } from "@vivsh1999/upupload/core";
const watermark = new Plugin<{ opacity: number }>({
id: "watermark",
name: "Watermark Plugin",
options: { opacity: 0.5 },
supports: (file) => file.type?.startsWith("image/") ?? false,
run: async (input, opts, classif, ctx) => {
// opts.opacity is typed as number
// classif.stemName, classif.ext — file metadata
// ctx.shared — inter-plugin communication
// ctx.log(level, message, extra?) — structured logging
// ctx.signal?: AbortSignal — cancellation support
// ctx.reportProgress(percent) — surface progress during long ops
return artifact("watermarked", input.file, classif.stemName + ".jpg", "image/jpeg");
},
});Register it like any built-in plugin:
useFileUpload({ plugins: [watermark.with({ opacity: 0.3 })] });Build a Thumbnail Plugin
A common use case is generating a smaller thumbnail variant alongside a full-size output. Here's a complete plugin that creates a 150×150 JPEG thumbnail using the Canvas API:
import { Plugin } from "@vivsh1999/upupload/plugins";
import { artifact, emptyResult } from "@vivsh1999/upupload/core";
interface ThumbnailOpts {
/** Max width/height in pixels. Default: 150 */
size?: number;
}
const thumbnailPlugin = new Plugin<ThumbnailOpts>({
id: "thumbnail",
name: "Thumbnail Generator",
options: { size: 150 },
supports: (file) => file.type?.startsWith("image/") ?? false,
run: async (input, opts, classif, ctx) => {
if (typeof OffscreenCanvas === "undefined") return emptyResult();
const img = await createImageBitmap(input.file);
const scale = Math.min(opts.size / img.width, opts.size / img.height, 1);
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
ctx.reportProgress?.(50); // surface progress
const canvas = new OffscreenCanvas(w, h);
const ctx2d = canvas.getContext("2d")!;
ctx2d.drawImage(img, 0, 0, w, h);
img.close();
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
return artifact("thumb", blob, `${classif.stemName}-thumb.jpg`, "image/jpeg");
},
});
// Usage:
useFileUpload({
plugins: [
jpegCompressor.with({ variant: "full", quality: 80 }),
thumbnailPlugin.with({ size: 150 }),
],
// uploadAdapter receives both "full" and "thumb" artifacts per file
uploadAdapter: async (artifact, helpers) => {
if (artifact.variant === "thumb") {
// upload to thumbnail bucket
} else {
// upload full-size
}
},
});Tip for multi-artifact setups: Each plugin variant produces a separate artifact. The
uploadAdapterreceives one call per artifact withartifactIndexandtotalArtifacts, letting you coordinate uploads.
Full guide: docs/plugins.md — covers createStages for multi-stage plugins, shared context patterns, after/before ordering, error handling, and testing.
Real example: examples/vanilla-html/custom-pipeline.js — a metadata-annotator plugin that reads image dimensions and writes JSON.
Publishing Plugins
If you've built a plugin others can use, publish it as a standalone npm package. See docs/plugins.md#publishing-a-plugin for the full checklist: naming conventions, supports() contract, shared keys, tree-shaking setup, JSR compliance, and testing requirements.
Documentation
| Topic | File | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Pipeline engine (stages, features, utilities) | docs/pipeline.md | | Plugin system (using, writing, publishing, testing) | docs/plugins.md | | React hook (useFileUpload, options, return value) | docs/react.md | | Configuration reference (all types) | docs/configuration.md | | Case study: e-commerce product photography | docs/case-studies/ecommerce-product-photography.md | | Case study: wedding photography client proofing | docs/case-studies/wedding-photography-uploader.md | | Case study: podcast audio publishing | docs/case-studies/podcast-audio-publishing.md |
Decoder Dependencies
The rawToJpeg plugin optionally imports decoders at runtime:
| Package | Format | Strategy |
| ------------- | -------------------------------- | --------------------------------------- |
| libraw-wasm | Camera RAW (CR3, DNG, NEF, ARW…) | Web Worker + WASM |
| heic-decode | HEIC/HEIF | Raw pixels, smaller bundle |
| heic2any | HEIC/HEIF | Fallback when heic-decode unavailable |
| utif | TIFF | Decodes to RGBA → JPEG |
npm add libraw-wasm heic-decode utifExamples
examples/vanilla-html— basic pipeline + custom pipeline with a metadata-annotator plugin. Demonstrates writing aPluginclass from scratch, composing multiple plugins, and inspecting the result.examples/tanstack-start— TanStack Start app with TUS uploads and the React hook. Shows end-to-end upload with theuseFileUploadhook.
Benchmarks
Autogenerated from vitest bench (via GitHub Actions — pushed to main).
Internal Components
| Benchmark | Ops/sec | Prev Minor (v0.6.1) | Change | | -------------------------------------------------------------------- | ------------- | ------------------- | ---------- | | audioBufferToWav > 1 sec mono @ 44100 | 2,711.28 | 2,677.56 | 🟢 +1.3% | | audioBufferToWav > 30 sec stereo @ 44100 | 118.00 | 46.94 | 🟢 +151.4% | | audioBufferToWav > 5 sec stereo @ 48000 | 613.39 | 256.29 | 🟢 +139.3% | | audioBufferToWav > empty buffer (no samples, mono @ 44100) | 743,616.65 | 852,496.99 | 🔴 -1.6% | | fileExtensionLower > .JPG → .jpg | 11,877,375.00 | 12,245,036.02 | 🔴 -1.6% | | fileExtensionLower > .Tar.Gz → .gz | 15,773,891.78 | 15,410,093.51 | 🟢 +2.4% | | fileExtensionLower > no extension → empty | 13,256,709.76 | 13,984,207.80 | 🔴 -1.5% | | info helper > level + message | 22,027,899.47 | 21,973,475.74 | 🟢 +0.2% | | info helper > level + message + code | 15,704,510.78 | 15,552,139.50 | 🟢 +1.0% | | result helpers > artifact | 96,571.01 | 101,602.79 | 🔴 -0.9% | | result helpers > emptyResult | 20,665,548.55 | 24,416,919.12 | 🔴 -1.0% | | result helpers > infoMessage | 21,097,255.59 | 18,124,403.93 | 🟢 +16.4% | | result helpers > warning | 20,521,131.06 | 18,523,131.19 | 🟢 +10.8% | | Semaphore > acquire — contended (concurrency=1, 2 tasks) | 3,053,933.17 | 3,476,596.59 | 🔴 -1.6% | | Semaphore > acquire + release — uncontended (concurrency=10, 1 task) | 6,881,202.76 | 6,651,517.06 | 🟢 +3.5% | | Semaphore > new Semaphore(4) | 21,923,821.43 | 26,361,787.05 | 🔴 -1.4% | | Semaphore > run() — 10 concurrent resolved promises | 381,183.38 | 384,412.00 | 🔴 -0.8% | | stem > archive.tar.gz → archive.tar | 17,499,800.81 | 16,890,750.72 | 🟢 +3.6% | | stem > noext → noext | 15,950,329.68 | 15,427,827.23 | 🟢 +3.4% | | stem > photo.jpg → photo | 18,190,772.94 | 17,271,813.59 | 🟢 +5.3% | | toJpegName > img.heic → img.jpg | 14,471,168.76 | 16,322,416.66 | 🔴 -1.5% | | toJpegName > photo.png → photo.jpg | 15,240,382.29 | 15,814,367.97 | 🔴 -0.6% | | toThumbName > img.heic → img.thumb.jpg | 16,906,787.70 | 16,770,101.97 | 🟢 +0.8% | | toThumbName > photo.png → photo.thumb.jpg | 15,983,023.63 | 16,132,803.61 | 🔴 -0.9% |
Internal Composition
| Benchmark | Ops/sec | Prev Minor (v0.6.1) | Change | | ------------------------------------------------------------------- | ------------- | ------------------- | --------- | | compose / stage > compose() 3 defs | 9,089,114.94 | 7,965,387.94 | 🟢 +14.1% | | compose / stage > stage() by id+run | 26,155,421.95 | 25,460,567.08 | 🟢 +2.7% | | createTimingMiddleware > wrap and run — no callback | 2,724,031.43 | 2,735,821.79 | 🔴 -0.4% | | createTimingMiddleware > wrap and run — with callback | 2,707,920.87 | 2,678,006.52 | 🟢 +1.1% | | flattenPipeline > 10 flat stages | 2,671,520.66 | 5,353,524.33 | 🔴 -1.2% | | flattenPipeline > 3 nested sub-pipelines (depth 3) | 4,611,429.07 | 5,184,584.39 | 🔴 -1.2% | | Pipeline factory > Pipeline() — 3 stages | 24,093,304.00 | 20,933,750.32 | 🟢 +15.1% | | resolvePipeline > first match (image → photos) | 11,477,003.13 | 13,000,647.09 | 🔴 -1.0% | | resolvePipeline > nested match (video → media → videos) | 10,112,859.76 | 11,005,393.67 | 🔴 -0.5% | | resolvePipeline > no match (text → null) | 12,440,125.60 | 14,894,685.58 | 🔴 -1.0% | | resolvePluginRefs > 5 bare Plugin instances (identity pass-through) | 19,353,801.69 | 16,868,115.12 | 🟢 +14.7% | | resolvePluginRefs > 5 PluginRef with defaults (no registry lookup) | 10,057,978.84 | 12,479,549.06 | 🔴 -1.6% | | resolvePluginRefs > 5 PluginRef with opts + .with() merging | 1,919,963.93 | 2,549,836.88 | 🔴 -1.7% | | runPipelineFrom > 3 stages via factory | 993,244.82 | 1,071,403.69 | 🔴 -1.8% | | sharedGet / sharedSet > sharedSet + sharedGet | 17,728,520.16 | 17,969,083.53 | 🔴 -1.3% | | validatePipeline > validatePipeline (nested, depth 4) | 2,634,232.96 | 2,408,698.36 | 🟢 +9.4% | | validatePipeline > validatePipeline (valid) | 3,727,407.41 | 3,783,063.55 | 🔴 -1.5% |
Plugins (Individual)
| Benchmark | Ops/sec | Prev Minor (v0.6.1) | Change | | ------------------------------------------------------- | ------------- | ------------------- | -------- | | Plugin class > new Plugin() with run shorthand | 22,611,349.77 | 26,441,849.10 | 🔴 -0.8% | | Plugin class > Plugin.createStages() | 2,141,657.57 | 2,740,633.42 | 🔴 -1.7% | | Plugin class > Plugin.supports() | 25,539,129.28 | 34,704,412.75 | 🔴 -1.1% | | Plugin class > Plugin.with() | 6,546,940.53 | 9,876,074.70 | 🔴 -0.7% | | Plugin class > Plugin.with() with instanceId | 6,796,549.33 | 7,273,144.23 | 🔴 -1.2% | | PluginProvider > new PluginProvider() | 1,741,709.31 | 1,870,987.90 | 🔴 -0.8% | | PluginProvider > PluginProvider camelCase method | 1,224,388.94 | 1,780,532.77 | 🔴 -1.2% | | PluginProvider > PluginProvider.getPlugin() — found | 562,020.93 | 1,875,394.28 | 🔴 -1.8% | | PluginProvider > PluginProvider.getPlugin() — not found | 1,027,755.49 | 1,855,247.90 | 🔴 -1.8% |
Plugins (Pipeline Composition)
| Benchmark | Ops/sec | Prev Minor (v0.6.1) | Change | | ------------------------------------------------------------- | ------------ | ------------------- | -------- | | dependsOn > 2 stages with dependsOn | 1,059,445.10 | 1,313,421.65 | 🔴 -1.3% | | parallel stages > 3 parallel stages | 697,023.72 | 966,324.33 | 🔴 -1.6% | | pipeline control flow > removeFromQueue | 1,695,465.38 | 2,173,207.10 | 🔴 -0.6% | | pipeline control flow > skipGroup | 1,405,969.86 | 1,615,213.96 | 🔴 -1.7% | | pipeline control flow > skipRemaining | 1,459,246.98 | 2,179,177.10 | 🔴 -0.9% | | runPipeline > 7 async stages (like real pipeline) | 118.17 | 120.58 | 🔴 -0.6% | | runPipeline > 7 stages with half skipped (when returns false) | 211.65 | 210.91 | 🟢 +0.4% | | runPipeline > stage error → onError fallback | 278.48 | 280.28 | 🔴 -0.6% | | runPipeline > stage error → onError skip | 282.86 | 281.95 | 🟢 +0.3% |
