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

@shipstatic/drop

v2.10.4

Published

Headless React hook that prepares files for ShipStatic deployments: drag and drop with folder support, ZIP extraction, path normalization, and validation.

Readme

@shipstatic/drop

Headless file processing for ShipStatic deployments.

A React hook that prepares files for deployment with @shipstatic/ship: drag & drop with folder support, ZIP extraction, path normalization, and validation against your account's real platform limits. No UI, full styling control.

Installation

npm install @shipstatic/drop @shipstatic/ship

React 18 or 19 is a peer dependency.

Quick start

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

const ship = new Ship({ token: 'deploy-your-token' });

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

  const upload = async () => {
    await ship.deployments.upload(drop.getFilesForUpload());
  };

  return (
    <div>
      <div
        {...drop.getDropzoneProps()}
        style={{
          border: '2px dashed',
          borderColor: drop.isDragging ? 'blue' : 'gray',
          padding: 40,
          textAlign: 'center',
        }}
      >
        <input {...drop.getInputProps()} />
        {drop.isDragging ? 'Drop here' : 'Click or drag a folder'}
      </div>

      {drop.status && (
        <p>
          {drop.status.title}: {drop.status.details}
        </p>
      )}

      <button onClick={upload} disabled={drop.validFiles.length === 0}>
        Deploy {drop.validFiles.length} files
      </button>
    </div>
  );
}

Why it exists

Ship's SDK deploys files. It doesn't do the browser-side work of getting them:

  • Folder drag & drop via webkitGetAsEntry, traversed to exhaustion (readEntries returns at most 100 entries per call, so a naive reader truncates large folders)
  • ZIP extraction, off the main thread
  • Path normalization — the common directory prefix is stripped so my-site/index.html deploys as index.html
  • Validation against your live limits from ship.getLimits(), using Ship's own validator so client and server can never disagree
  • React state for the whole lifecycle

useDrop(options)

const drop = useDrop({ ship });

| Option | Type | Purpose | |--------|------|---------| | ship | Pick<Ship, 'getLimits'> | Your Ship client — used for platform limits. A real Ship satisfies it. |

What it returns

interface DropReturn {
  // State
  phase: 'idle' | 'processing' | 'ready' | 'error';
  isProcessing: boolean;   // phase === 'processing'
  isDragging: boolean;     // pointer is over the dropzone
  isInteractive: boolean;  // idle or ready
  hasError: boolean;       // phase === 'error'
  files: ProcessedFile[];
  validFiles: ProcessedFile[];  // only those that passed validation
  sourceName: string;      // ZIP name, folder name, or filename
  status: DropStatus | null;
  needsBuild: boolean;

  // Prop getters
  getDropzoneProps: (options?: { clickable?: boolean }) => { ... };
  getInputProps: (mode?: PickerMode) => { ... };  // 'folder' (default) | 'files'

  // Actions
  open: (mode?: PickerMode) => void;             // trigger a picker (default: folder)
  processFiles: (files: File[]) => Promise<void>; // advanced — see below
  reset: () => void;

  getFilesForUpload: () => File[];  // raw Files for ship.deployments.upload()
}

isDragging is not a phase. It's a pointer state that can occur over any phase, so a ready set stays ready while a new folder is dragged over it. Switch on phase; style on isDragging.

Phases

idle → processing → ready   (deployable)
                  → error   (see status)

status carries what to show the user:

interface DropStatus {
  title: string;
  details: string;
  errors?: string[];    // per-file breakdown, on multi-error failures
  warnings?: string[];  // non-blocking, e.g. excluded empty files
}

To react to a phase change, use the state — that's what it's for:

useEffect(() => {
  if (drop.phase === 'ready') track('files_ready', drop.files.length);
}, [drop.phase]);

Prop getters

<div {...drop.getDropzoneProps()}>
  <input {...drop.getInputProps()} />
</div>

Drag-only, with your own triggers:

<div {...drop.getDropzoneProps({ clickable: false })}>
  <input {...drop.getInputProps('folder')} />
  <input {...drop.getInputProps('files')} />
  <button onClick={() => drop.open('folder')}>Select folder</button>
  <button onClick={() => drop.open('files')}>Select files</button>
</div>

getDropzoneProps() handles webkitGetAsEntry internally, which is what preserves folder structure. Calling processFiles() yourself loses it — the browser invalidates dataTransfer.items at the first await, so entries must be captured synchronously.

Two pickers

PickerMode is 'folder' | 'files', and folder is the default — a bare getInputProps() / open(), and the dropzone's own click, open the folder picker.

An <input> is either a folder picker or a file picker, so each mode owns its own element and its own ref: a UI offering both renders both inputs, and open(mode) clicks whichever is mounted. Exactly one attribute differs — webkitdirectory in folder mode, accept in files mode.

Wrap open in a handler rather than passing it by reference (onClick={() => drop.open('files')}): React hands a click handler a MouseEvent, which would otherwise arrive as the mode.

Selecting is not a second code path. A picked file set — loose files or a ZIP — runs the identical pipeline as a dropped one, with the same paths, the same source name and the same verdict. The accept list is a hint that biases what the file dialog shows first; it decides nothing, since every dialog offers an all-files escape and drag & drop ignores accept outright. What files may be deployed is one rule, applied downstream of both entry points.

Validation

Validation is atomic: if any file fails, every non-excluded file is marked validation_failed and nothing is deployable. Call reset() and start over.

Empty files (0 bytes) are excluded with a warning rather than failing the deploy.

| Status | Meaning | |--------|---------| | pending | Awaiting validation | | processing_error | Failed during processing | | excluded | Excluded with a warning — not an error | | validation_failed | Failed validation; blocks deployment | | ready | Deployable |

These are Ship's own values. Drop adds none of its own, so a ProcessedFile is directly expressible as Ship's ValidatableFile — and you compare against FileValidationStatus, imported from @shipstatic/ship, rather than a drop-specific alias:

import { FileValidationStatus } from '@shipstatic/ship';

const ready = drop.files.filter(f => f.status === FileValidationStatus.READY);

Build on upload

Drop recognises an unbuilt project (source files with package.json / node_modules) and sets needsBuild. node_modules is skipped during traversal and stripped from folder-picker selections, deploy validation is skipped (source files aren't build output), and every file goes straight to ready.

Pass the signal through to the SDK:

await ship.deployments.upload(drop.getFilesForUpload(), {
  build: drop.needsBuild,
  prerender: drop.needsBuild,
});

ZIP handling

A single dropped ZIP is extracted and its contents deployed. ZIPs among several files are treated as ordinary files. Archive paths are sanitized against directory traversal (../../config.json → config.json).

Without React

The pipeline is a plain function, so any UI layer can use it:

import { processFiles } from '@shipstatic/drop';
import { FileValidationStatus } from '@shipstatic/ship';

const outcome = await processFiles(files, { limits: await ship.getLimits() });

if (outcome.phase === 'ready') {
  const ready = outcome.files.filter(f => f.status === FileValidationStatus.READY);
  await ship.deployments.upload(ready.map(f => f.file));
} else {
  console.error(outcome.status.title, outcome.status.details);
}

It never throws — a missing entry point, an oversized file, an unbuilt project, and an unexpected failure all come back as an error outcome. Pass onStatus to report progress during extraction.

Testing your components

@shipstatic/drop/testing builds the fixtures so your tests don't have to:

import { createMockDrop, createMockProcessedFile } from '@shipstatic/drop/testing';

it('renders the file count', () => {
  const drop = createMockDrop({
    phase: 'ready',
    files: [createMockProcessedFile('index.html')],
  });

  render(<Dropzone drop={drop} />);
  expect(screen.getByText('1 file')).toBeInTheDocument();
});

Override any field — including with your own spies, which is how you assert on interactions:

const reset = vi.fn();
const drop = createMockDrop({ phase: 'ready', reset });

render(<Dropzone drop={drop} />);
await userEvent.click(screen.getByText('Clear'));

expect(reset).toHaveBeenCalled();

The subpath deliberately ships no spy or matcher helpers of its own — your test framework already has better ones.

| Export | Purpose | |--------|---------| | createMockDrop(overrides?) | A complete DropReturn; convenience booleans and validFiles derive from phase and files unless overridden | | createMockProcessedFile(name, options?) | A ProcessedFile backed by a real File | | createMockFileWithPath(name, path, content?, type?) | A real File carrying a folder-relative path | | mockUseDrop(overrides?) | A useDrop replacement, for components that call the hook themselves |

If your component receives drop as a prop, you need nothing else — pass it a createMockDrop(). If it calls useDrop internally, replace the module:

import { mockUseDrop } from '@shipstatic/drop/testing';

vi.mock('@shipstatic/drop', () => ({ useDrop: mockUseDrop({ phase: 'ready' }) }));

Gotchas

  • webkitRelativePath is the handoff. Drop writes each file's deploy path there, and the ShipStatic SDK reads it. Don't modify it in between.
  • stripCommonPrefix mutates File objects. It returns new ProcessedFiles but rewrites webkitRelativePath on the underlying File — deliberately, because that's what the SDK reads.
  • Unreadable entries are skipped silently. A folder with permission-denied files still deploys; the failures are logged to the console with no programmatic signal.
  • No MD5 here. Ship computes checksums during upload.
  • type is the browser's report. The platform derives Content-Type server-side from the path, so drop bundles no MIME database.

Also available

Part of ShipStatic. This package is a building block; the ways to actually deploy something are listed at shipstatic.com.

License

MIT