Skip to content

Repository files navigation

@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';importShipfrom'@shipstatic/ship';constship=newShip({token: 'deploy-your-token'});functionUploader(){constdrop=useDrop({ ship });constupload=async()=>{awaitship.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>)}<buttononClick={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)

constdrop=useDrop({ ship });
OptionTypePurpose
shipPick<Ship, 'getLimits'>Your Ship client — used for platform limits. A real Ship satisfies it.

What it returns

interfaceDropReturn{// Statephase: 'idle'|'processing'|'ready'|'error';isProcessing: boolean;// phase === 'processing'isDragging: boolean;// pointer is over the dropzoneisInteractive: boolean;// idle or readyhasError: boolean;// phase === 'error'files: ProcessedFile[];validFiles: ProcessedFile[];// only those that passed validationsourceName: string;// ZIP name, folder name, or filenamestatus: DropStatus|null;needsBuild: boolean;// Prop gettersgetDropzoneProps: (options?: {clickable?: boolean})=>{ ... };getInputProps: (mode?: PickerMode)=>{ ... };// 'folder' (default) | 'files'// Actionsopen: (mode?: PickerMode)=>void;// trigger a picker (default: folder)processFiles: (files: File[])=>Promise<void>;// advanced — see belowreset: ()=>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:

interfaceDropStatus{title: string;details: string;errors?: string[];// per-file breakdown, on multi-error failureswarnings?: 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')}/><buttononClick={()=>drop.open('folder')}>Select folder</button><buttononClick={()=>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.

StatusMeaning
pendingAwaiting validation
processing_errorFailed during processing
excludedExcluded with a warning — not an error
validation_failedFailed validation; blocks deployment
readyDeployable

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';constready=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:

awaitship.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.jsonconfig.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';constoutcome=awaitprocessFiles(files,{limits: awaitship.getLimits()});if(outcome.phase==='ready'){constready=outcome.files.filter(f=>f.status===FileValidationStatus.READY);awaitship.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',()=>{constdrop=createMockDrop({phase: 'ready',files: [createMockProcessedFile('index.html')],});render(<Dropzonedrop={drop}/>);expect(screen.getByText('1 file')).toBeInTheDocument();});

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

constreset=vi.fn();constdrop=createMockDrop({phase: 'ready', reset });render(<Dropzonedrop={drop}/>);awaituserEvent.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.

ExportPurpose
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 receivesdrop 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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages