
Security News
The Hidden Blast Radius of the Axios Compromise
The Axios compromise shows how time-dependent dependency resolution makes exposure harder to detect and contain.
Add file uploads to any web application. Secure, edge-ready. Works with 16+ frameworks and 5+ storage providers. No heavy AWS SDK required.

Pushduck is a type-safe file upload library for Next.js applications with S3-compatible storage providers. Built with modern React patterns and comprehensive TypeScript support.
onStart, onProgress, onSuccess, and onError# Using npm
npm install pushduck
# Using yarn
yarn add pushduck
# Using pnpm
pnpm add pushduck
// lib/upload.ts
import { createUploadConfig } from 'pushduck/server';
const { s3, config } = createUploadConfig()
.provider("aws", {
bucket: process.env.AWS_BUCKET_NAME!,
region: process.env.AWS_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
})
.defaults({
maxFileSize: '10MB',
acl: 'public-read',
})
.paths({
prefix: 'uploads',
generateKey: (file, metadata) => {
const userId = metadata.userId || 'anonymous';
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 8);
return `${userId}/${timestamp}/${randomId}/${file.name}`;
},
})
.build();
// Create router with your upload routes
const router = s3.createRouter({
imageUpload: s3.image().maxFileSize('5MB'),
documentUpload: s3.file({ maxSize: '10MB' }),
avatarUpload: s3.image().maxFileSize('2MB').middleware(async ({ metadata }) => ({
...metadata,
userId: metadata.userId || 'anonymous',
})),
});
export { router };
export type AppRouter = typeof router;
// app/api/upload/route.ts
import { router } from '@/lib/upload';
export const { GET, POST } = router.handlers;
// app/upload/page.tsx
'use client';
import { useUpload } from 'pushduck/client';
import type { AppRouter } from '@/lib/upload';
export default function UploadPage() {
const { uploadFiles, files, isUploading, error, reset } = useUpload<AppRouter>('imageUpload');
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// Optional: Pass client-side metadata
uploadFiles(selectedFiles, {
albumId: 'vacation-2025',
tags: ['summer'],
visibility: 'private'
});
};
return (
<div className="p-6">
<input
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
disabled={isUploading}
className="mb-4"
/>
{files.map((file) => (
<div key={file.id} className="mb-2 p-2 border rounded">
<div className="flex justify-between items-center">
<span className="font-medium">{file.name}</span>
<span className="text-sm text-gray-500">{file.status}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${file.progress}%` }}
/>
</div>
{file.status === 'success' && file.url && (
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm"
>
View uploaded file
</a>
)}
{file.status === 'error' && (
<p className="text-red-600 text-sm mt-1">Error: {file.error}</p>
)}
</div>
))}
<button
onClick={reset}
disabled={isUploading}
className="px-4 py-2 bg-gray-500 text-white rounded disabled:opacity-50"
>
Reset
</button>
</div>
);
}
Pushduck provides comprehensive callback support for handling the complete upload lifecycle:
const { uploadFiles } = useUpload<AppRouter>('imageUpload', {
// Called when upload process begins (after validation passes)
onStart: (files) => {
console.log(`Starting upload of ${files.length} files`);
setUploadStarted(true);
},
// Called with progress updates (0-100)
onProgress: (progress) => {
console.log(`Progress: ${progress}%`);
setProgress(progress);
},
// Called when all uploads complete successfully
onSuccess: (results) => {
console.log('Upload complete!', results);
setUploadStarted(false);
// Update your UI with uploaded file URLs
},
// Called when upload fails
onError: (error) => {
console.error('Upload failed:', error.message);
setUploadStarted(false);
// Show error message to user
},
});
The callbacks follow a predictable sequence:
onError is calledonStart → onProgress(0) → onProgress(n) → onSuccessonStart → onProgress(0) → onErrorThe onStart callback is perfect for:
onStart: (files) => {
// Show loading state immediately
setIsUploading(true);
// Display file list being uploaded
setUploadingFiles(files);
// Show toast notification
toast.info(`Uploading ${files.length} files...`);
// Disable form submission
setFormDisabled(true);
}
createUploadConfig().provider("aws", {
bucket: 'your-bucket',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
})
createUploadConfig().provider("cloudflareR2", {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
bucket: process.env.R2_BUCKET!,
accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY!,
region: 'auto',
})
createUploadConfig().provider("digitalOceanSpaces", {
region: 'nyc3',
bucket: 'your-space',
accessKeyId: process.env.DO_SPACES_ACCESS_KEY_ID!,
secretAccessKey: process.env.DO_SPACES_SECRET_ACCESS_KEY!,
})
createUploadConfig().provider("minio", {
endpoint: 'localhost:9000',
bucket: 'your-bucket',
accessKeyId: process.env.MINIO_ACCESS_KEY_ID!,
secretAccessKey: process.env.MINIO_SECRET_ACCESS_KEY!,
useSSL: false,
})
.defaults({
maxFileSize: '10MB',
allowedFileTypes: ['image/*', 'application/pdf'],
acl: 'public-read',
metadata: {
uploadedBy: 'system',
environment: process.env.NODE_ENV,
},
})
.paths({
prefix: 'uploads',
generateKey: (file, metadata) => {
const userId = metadata.userId || 'anonymous';
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 8);
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
return `${userId}/${timestamp}/${randomId}/${sanitizedName}`;
},
})
.hooks({
onUploadStart: async ({ file, metadata }) => {
console.log(`Starting upload: ${file.name}`);
},
onUploadComplete: async ({ file, metadata, url, key }) => {
console.log(`Upload complete: ${file.name} -> ${url}`);
// Save to database, send notifications, etc.
},
onUploadError: async ({ file, metadata, error }) => {
console.error(`Upload failed: ${file.name}`, error);
// Log error, send alerts, etc.
},
})
// Image validation
s3.image().maxFileSize('5MB')
// File validation
s3.file({ maxSize: '10MB', allowedTypes: ['application/pdf'] })
// Custom validation
s3.file().validate(async (file) => {
if (file.name.includes('virus')) {
throw new Error('Suspicious file detected');
}
})
// Middleware for metadata
s3.image().middleware(async ({ file, metadata }) => ({
...metadata,
processedAt: new Date().toISOString(),
}))
// Route-specific paths
s3.image().paths({
prefix: 'avatars',
generateKey: (file, metadata) => `user-${metadata.userId}/avatar.${file.name.split('.').pop()}`,
})
// Lifecycle hooks per route
s3.image().onUploadComplete(async ({ file, url, metadata }) => {
await updateUserAvatar(metadata.userId, url);
})
const {
uploadFiles, // (files: File[]) => Promise<void>
files, // UploadFile[] - reactive file state
isUploading, // boolean
error, // Error | null
reset, // () => void
} = useUpload<AppRouter>('routeName', {
onSuccess: (results) => console.log('Success:', results),
onError: (error) => console.error('Error:', error),
});
const {
uploadFiles,
files,
isUploading,
progress,
cancel,
retry,
} = useUploadRoute('routeName', {
onProgress: (progress) => console.log(`${progress.percentage}%`),
onComplete: (results) => console.log('Complete:', results),
});
For more control, use the upload client directly:
import { createUploadClient } from 'pushduck/client';
import type { AppRouter } from '@/lib/upload';
const client = createUploadClient<AppRouter>({
endpoint: '/api/upload',
});
// Upload files
await client.imageUpload.upload(files, {
onProgress: (progress) => console.log(`${progress.percentage}%`),
metadata: { userId: '123' },
});
function MultiUploadForm() {
const imageUpload = useUpload<AppRouter>('imageUpload');
const documentUpload = useUpload<AppRouter>('documentUpload');
return (
<div>
<div>
<h3>Images</h3>
<input
type="file"
accept="image/*"
multiple
onChange={(e) => imageUpload.uploadFiles(Array.from(e.target.files || []))}
/>
{/* Render image upload state */}
</div>
<div>
<h3>Documents</h3>
<input
type="file"
accept=".pdf,.doc,.docx"
multiple
onChange={(e) => documentUpload.uploadFiles(Array.from(e.target.files || []))}
/>
{/* Render document upload state */}
</div>
</div>
);
}
function CustomUploader() {
const { uploadFiles, files, isUploading } = useUpload<AppRouter>('imageUpload');
const [dragActive, setDragActive] = useState(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragActive(false);
const droppedFiles = Array.from(e.dataTransfer.files);
uploadFiles(droppedFiles);
};
return (
<div
className={`border-2 border-dashed p-8 text-center ${
dragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
onDragOver={(e) => e.preventDefault()}
onDragEnter={() => setDragActive(true)}
onDragLeave={() => setDragActive(false)}
onDrop={handleDrop}
>
{isUploading ? (
<p>Uploading...</p>
) : (
<p>Drag and drop files here, or click to select</p>
)}
</div>
);
}
# AWS S3
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
AWS_BUCKET_NAME=your_bucket
# Cloudflare R2
CLOUDFLARE_ACCOUNT_ID=your_account_id
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_key
R2_BUCKET=your_bucket
# DigitalOcean Spaces
DO_SPACES_ACCESS_KEY_ID=your_access_key
DO_SPACES_SECRET_ACCESS_KEY=your_secret_key
# MinIO
MINIO_ACCESS_KEY_ID=your_access_key
MINIO_SECRET_ACCESS_KEY=your_secret_key
If you're upgrading from an older version, see our Migration Guide for step-by-step instructions.
We welcome contributions! Please see our Contributing Guide for details.
This project is licensed under the MIT License - see the LICENSE file for details.
FAQs
Add file uploads to any web application. Secure, edge-ready. Works with 16+ frameworks and 5+ storage providers. No heavy AWS SDK required.
We found that pushduck demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
The Axios compromise shows how time-dependent dependency resolution makes exposure harder to detect and contain.

Research
A supply chain attack on Axios introduced a malicious dependency, plain-crypto-js@4.2.1, published minutes earlier and absent from the project’s GitHub releases.

Research
Malicious versions of the Telnyx Python SDK on PyPI delivered credential-stealing malware via a multi-stage supply chain attack.