npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@youglin/pushduck

v0.2.1-fix.1

Published

Add file uploads to any web application. Secure, edge-ready. Works with 16+ frameworks and 5+ storage providers. No heavy AWS SDK required.

Readme

Pushduck

NPM Version NPM Downloads Bundle Size GitHub Stars TypeScript License: MIT Discord Twitter

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.

Features

  • Easy Integration: Straightforward setup with Next.js App Router
  • Config-Aware Architecture: Type-safe configuration with multiple provider support
  • Type Safety: Full TypeScript support with schema validation
  • Progress Tracking: Real-time upload progress with comprehensive state management
  • Lifecycle Callbacks: Complete upload lifecycle with onStart, onProgress, onSuccess, and onError
  • Error Handling: Robust error handling with retry mechanisms
  • Cancellation: Cancel uploads with AbortController support
  • Multi-Provider: AWS S3, Cloudflare R2, DigitalOcean Spaces, MinIO, and more
  • Security: Private/public bucket support with presigned URLs
  • Validation: Built-in file type, size, and custom validation
  • Modern: Built for React 18+ and Next.js App Router

Installation

# Using npm
npm install pushduck

# Using yarn
yarn add pushduck

# Using pnpm
pnpm add pushduck

Quick Start

1. Configure Your Upload Settings

// 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;

2. Create API Route

// app/api/upload/route.ts
import { router } from '@/lib/upload';

export const { GET, POST } = router.handlers;

3. Use in Your Components

// 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>
  );
}

Upload Lifecycle Callbacks

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
  },
});

Callback Execution Order

The callbacks follow a predictable sequence:

  • Validation errors (size limits, file types): Only onError is called
  • Successful uploads: onStartonProgress(0)onProgress(n)onSuccess
  • Upload errors (network issues): onStartonProgress(0)onError

Using onStart for Better UX

The 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);
}

Configuration

Provider Setup

AWS S3

createUploadConfig().provider("aws", {
  bucket: 'your-bucket',
  region: 'us-east-1',
  accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
})

Cloudflare R2

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',
})

DigitalOcean Spaces

createUploadConfig().provider("digitalOceanSpaces", {
  region: 'nyc3',
  bucket: 'your-space',
  accessKeyId: process.env.DO_SPACES_ACCESS_KEY_ID!,
  secretAccessKey: process.env.DO_SPACES_SECRET_ACCESS_KEY!,
})

MinIO

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,
})

Advanced Configuration

File Validation & Defaults

.defaults({
  maxFileSize: '10MB',
  allowedFileTypes: ['image/*', 'application/pdf'],
  acl: 'public-read',
  metadata: {
    uploadedBy: 'system',
    environment: process.env.NODE_ENV,
  },
})

Path Configuration

.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}`;
  },
})

Lifecycle Hooks

.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.
  },
})

API Reference

Router Schema Methods

// 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);
})

Client Hooks

useUpload Hook

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),
});

useUploadRoute Hook

const {
  uploadFiles,
  files,
  isUploading,
  progress,
  cancel,
  retry,
} = useUploadRoute('routeName', {
  onProgress: (progress) => console.log(`${progress.percentage}%`),
  onComplete: (results) => console.log('Complete:', results),
});

Upload Client

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' },
});

Examples

Multi-Route Upload Form

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>
  );
}

Custom Upload Component

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>
  );
}

Environment Variables

# 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

Migration Guide

If you're upgrading from an older version, see our Migration Guide for step-by-step instructions.

Contributing

We welcome contributions! Please see our Contributing Guide for details.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Support