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 🙏

© 2025 – Pkg Stats / Ryan Hefner

@shipstatic/drop

v0.1.8

Published

Headless React hook for file dropping, processing, ZIP extraction, and validation - purpose-built for Ship SDK

Readme

@shipstatic/drop

Headless file processing toolkit for Ship SDK deployments

A focused React hook for preparing files for deployment with @shipstatic/ship. Handles ZIP extraction, path normalization, and validation - everything needed before calling ship.deploy().

Built-in drag & drop support with prop getters! No need to manually implement drag & drop handlers - just spread {...drop.getDropzoneProps()} on your container and {...drop.getInputProps()} on the input element. Folder structure preservation is handled automatically.

Note: MD5 calculation is handled by Ship SDK during deployment. Drop focuses on file processing and UI state management.

Table of Contents

Why Headless?

This package provides zero UI components - just a React hook with built-in drag & drop functionality. You bring your own styling.

What you get:

  1. Built-in drag & drop - Proper folder support with webkitGetAsEntry API, all handled internally
  2. Prop getters API - Similar to react-dropzone, just spread props on your elements
  3. Full styling control - No imposed CSS, design system, or theming
  4. Ship SDK integration - Purpose-built for Ship deployments, not a generic file upload library

What's different from other libraries:

  • Generic dropzone libraries don't preserve folder structure properly
  • We handle the complex parts (ZIP extraction, folder traversal, path normalization)
  • You handle the simple parts (styling, layout, animations)

Features

  • 🎯 Prop Getters API - Just spread props on your elements (like react-dropzone)
  • 🖱️ Built-in Drag & Drop - Automatic folder support with webkitGetAsEntry API
  • 📦 ZIP Support - Automatic ZIP file extraction and processing
  • Validation - Client-side file size, count, and total size validation (powered by Ship SDK)
  • 🗑️ Junk Filtering - Automatically filters .DS_Store, Thumbs.db, etc. (powered by Ship SDK)
  • 🔒 Path Sanitization - Defense-in-depth protection against directory traversal attacks
  • 📁 Folder Structure Preservation - Proper folder paths via webkitRelativePath
  • 🎨 Headless UI - No visual components, just logic and state management
  • 📘 Full TypeScript Support - Complete type definitions with discriminated unions for state machine
  • 🚀 Focused Scope - File processing and UI state only. MD5 calculation and deployment handled by Ship SDK

Installation

npm install @shipstatic/drop
# or
pnpm add @shipstatic/drop

Requirements

  • React: ^18.0.0 or ^19.0.0
  • TypeScript: Full TypeScript support with exported types
  • Browsers: Modern browsers with support for:
    • File API (universal support)
    • DataTransfer API for drag & drop (universal support)
    • webkitGetAsEntry for folder uploads (Chrome, Edge, Safari 11.1+, Firefox 50+)

Note on folder uploads: The folder drag & drop feature uses the webkitGetAsEntry API. While widely supported, older browsers may only support file-by-file selection. ZIP extraction works universally as a fallback.

Quick Start

import { useDrop } from '@shipstatic/drop';
import Ship from '@shipstatic/ship';

const ship = new Ship({ deployToken: 'token-xxxx' });

function MyUploader() {
  const drop = useDrop({
    ship  // Pass Ship instance - Drop uses ship.getConfig() for validation
  });

  const handleUpload = async () => {
    const validFiles = drop.getValidFiles();
    // ProcessedFile extends StaticFile - no conversion needed!
    await ship.deployments.create(validFiles.map(f => f.file));
  };

  return (
    <div>
      {/* Drag & drop zone with built-in folder support */}
      <div
        {...drop.getDropzoneProps()}
        style={{
          border: '2px dashed',
          borderColor: drop.isDragging ? 'blue' : 'gray',
          padding: '40px',
          textAlign: 'center',
          cursor: 'pointer',
        }}
      >
        <input {...drop.getInputProps()} />
        {drop.isDragging ? '📂 Drop here' : '📁 Click or drag files/folders'}
      </div>

      {/* Status - using state machine */}
      {drop.state.status && (
        <p>
          <strong>{drop.state.status.title}:</strong> {drop.state.status.details}
        </p>
      )}

      {/* File list */}
      {drop.state.files.map(file => (
        <div key={file.id}>
          {file.name} - {file.status}
        </div>
      ))}

      {/* Upload button */}
      <button
        onClick={handleUpload}
        disabled={drop.state.value !== 'ready'}
      >
        Upload {drop.getValidFiles().length} files
      </button>
    </div>
  );
}

⚠️ Configuration Architecture

Drop uses Ship's validation config automatically:

Drop accepts a Ship instance and uses ship.getConfig() internally. This ensures:

  • Single source of truth - Validation config comes from Ship SDK
  • Always in sync - Client validation matches server limits
  • No manual config fetching - Drop handles it internally
  • Simpler API - Just pass ship instance
// Drop fetches config from Ship SDK automatically
const drop = useDrop({ ship });

// Behind the scenes:
// 1. Ship SDK fetches /config on initialization
// 2. Drop calls ship.getConfig() when validating
// 3. Validation always uses current server limits

Why this architecture:

  • Drop has NO validation rules of its own - it's a pure proxy
  • Ship SDK is the single source of truth for validation
  • Drop only provides what Ship doesn't have (ZIP, React state, folder structure)

Advanced: Programmatic File Picker

You can programmatically trigger the file picker using the open() method:

function MyUploader() {
  const drop = useDrop({ ship });

  return (
    <div>
      {/* Custom trigger button */}
      <button onClick={drop.open}>
        Select Files
      </button>

      {/* Hidden input managed by the hook */}
      <input {...drop.getInputProps()} />

      {/* Or use the dropzone */}
      <div {...drop.getDropzoneProps()}>
        Drop files here
      </div>
    </div>
  );
}

Advanced: Manual File Processing

For advanced use cases, you can manually process files instead of using prop getters:

function AdvancedUploader() {
  const drop = useDrop({ ship });

  const handleManualDrop = async (e: React.DragEvent) => {
    e.preventDefault();
    const files = Array.from(e.dataTransfer.files);
    await drop.processFiles(files);
  };

  return (
    <div onDrop={handleManualDrop}>
      {/* Custom implementation */}
    </div>
  );
}

Note: When using manual processing, you lose automatic folder structure preservation. The built-in getDropzoneProps() handles webkitGetAsEntry API internally to preserve folder paths.

API

useDrop(options?)

Main hook for managing drop state.

Options:

interface DropOptions {
  /** Ship SDK instance (required for validation) */
  ship: Ship;
  /** Callback when validation fails */
  onValidationError?: (error: ClientError) => void;
  /** Callback when files are ready for upload */
  onFilesReady?: (files: ProcessedFile[]) => void;
  /** Whether to strip common directory prefix from paths (default: true) */
  stripPrefix?: boolean;
}

Returns:

interface DropReturn {
  // State machine
  /** Current state of the drop hook */
  state: DropState;

  // Convenience getters (computed from state)
  /** Whether currently processing files (ZIP extraction, etc.) */
  isProcessing: boolean;
  /** Whether user is currently dragging over the dropzone */
  isDragging: boolean;

  // Primary API: Prop getters for easy integration
  /** Get props to spread on dropzone element (handles drag & drop) */
  getDropzoneProps: () => {
    onDragOver: (e: React.DragEvent) => void;
    onDragLeave: (e: React.DragEvent) => void;
    onDrop: (e: React.DragEvent) => void;
    onClick: () => void;
  };
  /** Get props to spread on hidden file input element */
  getInputProps: () => {
    ref: React.RefObject<HTMLInputElement | null>;
    type: 'file';
    style: { display: string };
    multiple: boolean;
    webkitdirectory: string; // Note: React expects string ('') for boolean attributes
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  };

  // Actions
  /** Programmatically trigger file picker */
  open: () => void;
  /** Manually process files (for advanced usage) */
  processFiles: (files: File[]) => Promise<void>;
  /** Clear all files and reset state */
  clearAll: () => void;

  // Helpers
  /** Get only valid files ready for upload */
  getValidFiles: () => ProcessedFile[];
  /** Update upload state for a specific file (status, progress, message) */
  updateFileStatus: (fileId: string, state: {
    status: FileStatus;
    statusMessage?: string;
    progress?: number;
  }) => void;
}

// State machine types
type DropStateValue =
  | 'idle'       // The hook is ready for files
  | 'dragging'   // The user is dragging files over the dropzone
  | 'processing' // Files are being validated and processed
  | 'ready'      // Files are valid and ready for deployment
  | 'error';     // An error occurred during processing

interface DropState {
  value: DropStateValue;
  files: ProcessedFile[];
  sourceName: string;
  status: DropStatus | null;
}

interface DropStatus {
  title: string;
  details: string;
}

State Machine

The drop hook uses a state machine for predictable, clear state management. Instead of multiple boolean flags, you have a single state.value that represents exactly what's happening.

State Flow

idle → dragging → idle (drag leave without drop)
idle → dragging → processing → ready (successful)
idle → dragging → processing → error (failed)
ready → dragging → processing → ... (new drop)
error → dragging → processing → ... (retry)

Using the State Machine

function StatusIndicator({ drop }) {
  const { state } = drop;

  switch (state.value) {
    case 'idle':
      return <p>Drop files here or click to select</p>;

    case 'dragging':
      return <p>Drop your files now!</p>;

    case 'processing':
      return <p>{state.status?.details || 'Processing...'}</p>;

    case 'ready':
      return (
        <div>
          <p>✓ {state.files.length} files ready</p>
          <button>Upload to Ship</button>
        </div>
      );

    case 'error':
      return (
        <div>
          <p>✗ {state.status?.title}</p>
          <p>{state.status?.details}</p>
          <button onClick={drop.clearAll}>Try Again</button>
        </div>
      );
  }
}

Convenience Getters

For simpler use cases, boolean convenience getters are provided:

// These are computed from state.value (read-only projections)
drop.isProcessing  // true when state.value === 'processing'
drop.isDragging    // true when state.value === 'dragging'

// For error information, use the state object
drop.state.value === 'error'  // Check if in error state
drop.state.status?.title      // Error title
drop.state.status?.details    // Error details

Benefits

  • No impossible states - Can't be isProcessing=true AND isDragging=true
  • Clear transitions - State flow is explicit and predictable
  • Better TypeScript - Discriminated unions provide type safety
  • Easier debugging - Single source of truth for what's happening

Error Handling

Per-File Error Display

Each file in the state.files array contains its own status and statusMessage, allowing you to display granular errors for individual files:

function FileList({ drop }) {
  return (
    <div>
      {drop.state.files.map(file => (
        <div key={file.id}>
          <span>{file.path}</span>

          {/* Show status indicator */}
          {file.status === 'ready' ? '✓' : '✗'}

          {/* Show per-file error message */}
          {file.status !== 'ready' && file.statusMessage && (
            <span style={{ color: 'red' }}>
              {file.statusMessage}
            </span>
          )}
        </div>
      ))}

      {/* If validation fails, allow user to clear all and try again */}
      {drop.state.value === 'error' && (
        <button onClick={drop.clearAll}>
          Clear All & Try Again
        </button>
      )}
    </div>
  );
}

Common error statuses:

  • validation_failed - File failed validation (size, type, name, etc.)
  • processing_error - MD5 calculation or processing failed
  • empty_file - File is 0 bytes
  • ready - File passed all validation and is ready for upload

Error State Summary

When files fail validation or processing, check the error state:

{drop.state.value === 'error' && drop.state.status && (
  <div>
    <p>{drop.state.status.title}</p>
    <p>{drop.state.status.details}</p>
  </div>
)}

Atomic Validation: If ANY file fails validation, ALL files are marked as validation_failed. This ensures deployments are all-or-nothing for data integrity. The Ship SDK follows this same pattern server-side.

No Individual File Removal

The Drop package intentionally does not support removing individual files. Here's why:

Reason: Ship SDK uses atomic validation - if ANY file fails validation, ALL files are marked as validation_failed. This ensures deployments are all-or-nothing for data integrity.

The Problem with Individual Removal:

// User drops 5 files, 1 is too large
// Atomic validation: ALL 5 files marked as validation_failed

// If we allowed removing the large file:
drop.removeFile(largeFileId); // ❌ We don't support this!

// Would need to re-validate remaining 4 files
// Creates complexity and race conditions

The Simple Solution: Use clearAll() to reset and try again:

// If validation fails, show user which files failed
{drop.state.value === 'error' && (
  <div>
    <p>Validation failed. Please fix the issues and try again:</p>
    {drop.state.files.map(file => (
      <div key={file.id}>
        {file.path}: {file.statusMessage}
      </div>
    ))}
    <button onClick={drop.clearAll}>Clear All & Try Again</button>
  </div>
)}

Benefits:

  • ✅ No race conditions or stale validation state
  • ✅ Simpler mental model (atomic = all-or-nothing)
  • ✅ Aligns with Ship SDK's validation philosophy
  • ✅ Clear UX: fix the problem, then re-drop

Types

/**
 * ProcessedFile extends StaticFile from @shipstatic/types
 * This means it can be passed directly to ship.deployments.create()
 *
 * Note: md5 is intentionally undefined - Ship SDK calculates it during deployment
 */
interface ProcessedFile extends StaticFile {
  // StaticFile properties (SDK compatibility)
  content: File;        // File object (required by SDK)
  path: string;         // Normalized path (webkitRelativePath or file.name)
  size: number;         // File size in bytes
  md5?: string;         // Undefined - Ship SDK calculates during deployment

  // ProcessedFile-specific properties (UI functionality)
  id: string;           // Unique identifier for React keys
  file: File;           // Alias for 'content' (better DX)
  name: string;         // File name without path
  type: string;         // MIME type
  lastModified: number;
  status: FileStatus;
  statusMessage?: string;  // Per-file error message
  progress?: number;       // Upload progress (0-100)
}

interface ClientError {
  error: string;
  details: string;
  isClientError: true;
}

type FileStatus =
  | 'pending'
  | 'ready'
  | 'uploading'
  | 'complete'
  | 'processing_error'
  | 'error'
  | 'validation_failed'
  | 'empty_file';

Direct Ship SDK Integration

ProcessedFile extends StaticFile - no conversion needed! Since ProcessedFile extends StaticFile from @shipstatic/types, you can pass the files directly to the Ship SDK:

const validFiles = drop.getValidFiles();

// ProcessedFile[] IS StaticFile[] - pass directly!
await ship.deployments.create({ files: validFiles });

Type Compatibility

// ✅ This works because ProcessedFile extends StaticFile
interface ProcessedFile extends StaticFile {
  content: File;   // Required by StaticFile
  path: string;    // Required by StaticFile
  size: number;    // Required by StaticFile
  md5?: string;    // Required by StaticFile

  // Additional UI properties
  id: string;
  file: File;      // Alias for 'content' (better DX)
  name: string;
  type: string;
  status: FileStatus;
  // ... etc
}

Important: The drop hook preserves folder structure via webkitRelativePath and processes paths with stripCommonPrefix automatically. The path property is always deployment-ready.

Architecture Decisions

Why Drop Doesn't Calculate MD5

Design Philosophy: Drop should only provide what Ship SDK doesn't have.

What Drop provides:

  • ✅ ZIP extraction (Ship SDK doesn't have this)
  • ✅ React state management (Ship SDK doesn't have this)
  • ✅ Folder structure preservation (UI-specific concern)
  • ✅ Path normalization (UI-specific concern)

What Ship SDK provides:

  • ✅ MD5 calculation (already implemented)
  • ✅ Validation (already implemented)
  • ✅ Deployment (core functionality)

Why this matters:

  • Avoids duplicate MD5 calculation (performance)
  • Single source of truth for deployment logic
  • Drop stays focused on UI concerns
  • Ship SDK handles all deployment concerns

StaticFile.md5 is optional - Ship SDK calculates it during deployment if not provided.

Why Not Abstract?

This package was extracted from the web/drop application and is purpose-built for Ship SDK. Key decisions:

1. Focused on UI Concerns

  • ZIP extraction for user convenience
  • File list state management for React UIs
  • Drag & drop event handling with folder support
  • Folder structure preservation via webkitGetAsEntry API
  • Path normalization for clean URLs
  • These are UI/UX concerns, not deployment logic

2. Prop Getters Pattern (Like react-dropzone) We provide event handlers, not visual components:

  • getDropzoneProps() - Returns drag & drop event handlers
  • getInputProps() - Returns file input configuration
  • isDragging - State for visual feedback
  • You control the DOM - Your markup, your styling, your design system

This is the same pattern used by popular libraries like react-dropzone, downshift, and react-table.

3. Loosely Coupled Integration Following industry standards (Firebase hooks, Supabase utilities), we chose:

  • Ship instance as dependency: Validates using ship.getConfig()
  • Simple output: ProcessedFile[] can be passed directly to Ship SDK
  • Testable: Easy to mock Ship SDK for testing
  • Flexible: Host app controls WHEN to deploy

4. Type System Integration

ProcessedFile extends StaticFile from @shipstatic/types - the single source of truth for Ship SDK types:

File[] → ProcessedFile[] (which IS StaticFile[]) → ship.deployments.create()

No conversion needed. ProcessedFile adds UI-specific properties (id, name, status, progress) to StaticFile's base properties (content, path, size, md5).

5. No Visual Components

We deliberately don't provide styled components because:

  • Your design system is unique to your application
  • Styling should match your brand, not our opinions
  • Prop getters give you full control over DOM structure and CSS
  • Similar to how react-dropzone works - logic without opinions

Error Handling Philosophy

All errors are surfaced at the per-file level:

  • Each file has its own status and statusMessage property
  • Processing errors (e.g., ZIP extraction failures) are marked with status: 'processing_error'
  • Validation failures are marked with status: 'validation_failed'
  • The statusMessage always contains specific error details
  • Failed files are excluded from getValidFiles() and cannot be deployed
  • No silent failures - all errors are visible to users

See the Error Handling section for examples of displaying per-file errors in your UI.

Security

Path Sanitization: ZIP extraction includes defense-in-depth protection against directory traversal attacks:

  • Normalizes all file paths by removing .., ., and empty segments
  • Prevents traversal above the root directory
  • Converts absolute paths to relative paths
  • Skips files that resolve to empty paths after normalization
  • Comprehensive test coverage for various attack vectors

While the Ship SDK validates paths server-side, client-side sanitization provides an additional security layer and prevents malicious paths from ever reaching the server.

Concurrency Protection: The processFiles function includes built-in race condition protection:

  • Uses a synchronous ref guard to prevent concurrent processing
  • Automatically ignores duplicate calls while processing is in progress
  • Logs warnings when concurrent calls are detected
  • Ensures the processing flag is always cleared, even on errors
  • Makes the hook robust regardless of UI implementation

License

MIT