@vivsh1999/upupload
v0.8.0
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 to a pool of Web Workers via
useWorker: true - One-Decode Multi-Variant JPEGs —
jpegCompressor.with({ variants: [...] })encodes every size from one decode - 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 | Initial BrowserPipelineOptions — every field forwarded |
| 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 | | -------------------------------------------------------------------- | ------------- | ------------------- | ---------- | | fileExtensionLower > .JPG → .jpg | 10,177,393.37 | 11,601,741.00 | 🔴 -0.5% | | fileExtensionLower > .Tar.Gz → .gz | 10,232,653.98 | 11,004,986.68 | 🔴 -0.6% | | fileExtensionLower > no extension → empty | 11,724,829.13 | 11,146,206.37 | 🟢 +5.2% | | stem > photo.jpg → photo | 11,740,859.72 | 11,202,007.13 | 🟢 +4.8% | | stem > archive.tar.gz → archive.tar | 11,783,509.60 | 11,736,924.64 | 🟢 +0.4% | | stem > noext → noext | 11,655,900.93 | 11,002,523.25 | 🟢 +5.9% | | toJpegName > photo.png → photo.jpg | 10,991,378.29 | 10,493,276.09 | 🟢 +4.7% | | toJpegName > img.heic → img.jpg | 11,394,062.25 | 10,932,260.23 | 🟢 +4.2% | | toThumbName > photo.png → photo.thumb.jpg | 11,012,077.52 | 11,343,684.62 | 🔴 -0.7% | | toThumbName > img.heic → img.thumb.jpg | 11,336,221.00 | 10,873,871.89 | 🟢 +4.3% | | info helper > level + message | 14,330,560.74 | 14,496,825.74 | 🔴 -1.1% | | info helper > level + message + code | 11,320,194.75 | 11,517,943.55 | 🔴 -1.7% | | result helpers > emptyResult | 16,217,454.15 | 16,309,178.14 | 🔴 -0.6% | | result helpers > artifact | 65,289.76 | 63,004.83 | 🟢 +3.6% | | result helpers > warning | 15,774,066.77 | 11,401,194.38 | 🟢 +38.4% | | result helpers > infoMessage | 16,186,364.41 | 11,799,781.06 | 🟢 +37.2% | | Semaphore > new Semaphore(4) | 16,435,531.97 | 16,297,940.83 | 🟢 +0.8% | | Semaphore > acquire + release — uncontended (concurrency=10, 1 task) | 5,178,969.37 | 4,784,996.58 | 🟢 +8.2% | | Semaphore > acquire — contended (concurrency=1, 2 tasks) | 2,337,090.05 | 2,266,078.16 | 🟢 +3.1% | | Semaphore > run() — 10 concurrent resolved promises | 273,633.75 | 269,066.13 | 🟢 +1.7% | | audioBufferToWav > empty buffer (no samples, mono @ 44100) | 432,135.18 | 433,889.18 | 🔴 -0.4% | | audioBufferToWav > 1 sec mono @ 44100 | 1,697.58 | 1,625.12 | 🟢 +4.5% | | audioBufferToWav > 5 sec stereo @ 48000 | 325.33 | 147.17 | 🟢 +121.1% | | audioBufferToWav > 30 sec stereo @ 44100 | 61.5832 | 27.93 | 🟢 +120.5% |
Internal Composition
| Benchmark | Ops/sec | Prev Minor (v0.6.1) | Change | | ------------------------------------------------------------------- | ------------- | ------------------- | --------- | | resolvePluginRefs > 5 bare Plugin instances (identity pass-through) | 13,349,895.01 | 13,439,183.33 | 🔴 -0.7% | | resolvePluginRefs > 5 PluginRef with opts + .with() merging | 1,792,435.73 | 1,720,503.33 | 🟢 +4.2% | | resolvePluginRefs > 5 PluginRef with defaults (no registry lookup) | 8,820,885.65 | 8,657,961.57 | 🟢 +1.9% | | resolvePipeline > first match (image → photos) | 8,297,259.34 | 7,918,530.72 | 🟢 +4.8% | | resolvePipeline > nested match (video → media → videos) | 6,386,689.83 | 6,630,763.26 | 🔴 -0.7% | | resolvePipeline > no match (text → null) | 9,549,645.92 | 9,142,955.10 | 🟢 +4.4% | | validatePipeline > validatePipeline (valid) | 2,606,360.64 | 2,605,808.20 | 🟢 +0.0% | | validatePipeline > validatePipeline (nested, depth 4) | 1,672,549.59 | 1,672,349.01 | 🟢 +0.0% | | compose / stage > stage() by id+run | 16,087,985.03 | 16,174,314.16 | 🔴 -0.5% | | compose / stage > compose() 3 defs | 6,361,251.85 | 5,741,249.95 | 🟢 +10.8% | | sharedGet / sharedSet > sharedSet + sharedGet | 12,612,964.84 | 12,585,050.97 | 🟢 +0.2% | | createTimingMiddleware > wrap and run — no callback | 1,722,308.77 | 1,714,926.53 | 🟢 +0.4% | | createTimingMiddleware > wrap and run — with callback | 1,719,339.11 | 1,642,540.30 | 🟢 +4.7% | | Pipeline factory > Pipeline() — 3 stages | 16,176,169.39 | 15,771,021.97 | 🟢 +2.6% | | flattenPipeline > 10 flat stages | 4,146,984.80 | 4,146,631.93 | 🟢 +0.0% | | flattenPipeline > 3 nested sub-pipelines (depth 3) | 5,414,964.03 | 3,612,330.85 | 🟢 +49.9% | | runPipelineFrom > 3 stages via factory | 685,736.99 | 750,199.75 | 🔴 -0.7% |
Plugins (Individual)
| Benchmark | Ops/sec | Prev Minor (v0.6.1) | Change | | ------------------------------------------------------- | ------------- | ------------------- | -------- | | Plugin class > new Plugin() with run shorthand | 16,347,020.95 | 16,093,985.03 | 🟢 +1.6% | | Plugin class > Plugin.supports() | 20,370,081.55 | 20,309,563.92 | 🟢 +0.3% | | Plugin class > Plugin.with() | 6,546,776.30 | 6,636,644.79 | 🔴 -1.4% | | Plugin class > Plugin.with() with instanceId | 5,288,043.60 | 5,608,928.37 | 🔴 -1.2% | | Plugin class > Plugin.createStages() | 1,292,151.82 | 1,346,234.39 | 🔴 -0.6% | | PluginProvider > new PluginProvider() | 1,197,408.46 | 1,214,800.45 | 🔴 -1.4% | | PluginProvider > PluginProvider camelCase method | 1,115,610.82 | 1,110,112.47 | 🟢 +0.5% | | PluginProvider > PluginProvider.getPlugin() — found | 1,178,309.38 | 1,200,972.63 | 🔴 -1.0% | | PluginProvider > PluginProvider.getPlugin() — not found | 1,182,091.44 | 1,173,766.09 | 🟢 +0.7% |
Plugins (Pipeline Composition)
| Benchmark | Ops/sec | Prev Minor (v0.6.1) | Change | | ------------------------------------------------------------- | ------------ | ------------------- | -------- | | runPipeline > 7 async stages (like real pipeline) | 126.32 | 125.01 | 🟢 +1.0% | | runPipeline > 7 stages with half skipped (when returns false) | 220.48 | 220.65 | 🔴 -0.1% | | runPipeline > stage error → onError fallback | 293.95 | 294.29 | 🔴 -0.1% | | runPipeline > stage error → onError skip | 297.59 | 297.73 | 🔴 -0.0% | | pipeline control flow > skipGroup | 974,397.23 | 910,747.00 | 🟢 +7.0% | | pipeline control flow > skipRemaining | 1,428,883.47 | 1,445,397.60 | 🔴 -1.1% | | pipeline control flow > removeFromQueue | 1,422,919.44 | 1,379,275.75 | 🟢 +3.2% | | parallel stages > 3 parallel stages | 609,794.27 | 596,138.56 | 🟢 +2.3% | | dependsOn > 2 stages with dependsOn | 795,907.57 | 819,777.43 | 🔴 -1.0% |
