@shipstatic/drop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,444 @@
1
+ # @shipstatic/assets
2
+
3
+ **Headless file processing toolkit for Ship SDK deployments**
4
+
5
+ A focused React hook for preparing files for deployment with [@shipstatic/ship](https://github.com/shipstatic/ship). Handles ZIP extraction, MD5 calculation, path normalization, and validation - everything needed before calling `ship.deploy()`.
6
+
7
+ ## Why Headless?
8
+
9
+ This package provides **zero UI components**. You build the dropzone that fits your needs. Why?
10
+
11
+ 1. **Folder structure matters** - Proper folder drag-and-drop requires modern browser APIs (`File System Access API`, `webkitGetAsEntry`) that generic dropzone libraries don't support
12
+ 2. **Full control** - Your UI, your styling, your UX patterns
13
+ 3. **Smaller bundle** - No React components, no extra dependencies (~14KB saved vs generic libraries)
14
+ 4. **Ship SDK integration** - Purpose-built for Ship deployments, not a generic file upload library
15
+
16
+ The package focuses on what's hard (ZIP extraction, MD5 calculation, validation) and leaves what's easy (UI) to you.
17
+
18
+ ## Features
19
+
20
+ - 🎯 **Headless Architecture** - Just the hook, no UI opinions
21
+ - 📦 **ZIP Support** - Automatic ZIP file extraction and processing
22
+ - ✅ **Validation** - Client-side file size, count, and total size validation
23
+ - 🗑️ **Junk Filtering** - Automatically filters `.DS_Store`, `Thumbs.db`, etc.
24
+ - 🔍 **MD5 Hashing** - Calculates MD5 checksums for all files
25
+ - 🔒 **Path Sanitization** - Defense-in-depth protection against directory traversal attacks
26
+ - 📁 **Folder Structure Preservation** - Respects `webkitRelativePath` for proper deployment paths
27
+ - 🚀 **SDK Agnostic** - Works with any upload SDK (Ship, AWS S3, etc.)
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ npm install @shipstatic/assets
33
+ # or
34
+ pnpm add @shipstatic/assets
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```tsx
40
+ import { useDropzoneManager } from '@shipstatic/assets';
41
+ import Ship from '@shipstatic/ship';
42
+
43
+ function MyUploader() {
44
+ const ship = new Ship({ apiUrl: '...' });
45
+
46
+ // Get validation config from Ship SDK
47
+ const config = await ship.getConfig();
48
+
49
+ const dropzone = useDropzoneManager({
50
+ config // Pass SDK config directly
51
+ });
52
+
53
+ const handleUpload = async () => {
54
+ const validFiles = dropzone.getValidFiles();
55
+
56
+ // ProcessedFile extends StaticFile - no conversion needed!
57
+ await ship.deployments.create({ files: validFiles });
58
+ };
59
+
60
+ return (
61
+ <div>
62
+ <input
63
+ type="file"
64
+ multiple
65
+ onChange={(e) => {
66
+ const files = Array.from(e.target.files || []);
67
+ dropzone.processFiles(files);
68
+ }}
69
+ />
70
+
71
+ <p>{dropzone.statusText}</p>
72
+
73
+ {dropzone.files.map(file => (
74
+ <div key={file.id}>
75
+ {file.name} - {file.status}
76
+ </div>
77
+ ))}
78
+
79
+ <button
80
+ onClick={handleUpload}
81
+ disabled={dropzone.getValidFiles().length === 0}
82
+ >
83
+ Upload {dropzone.getValidFiles().length} files
84
+ </button>
85
+ </div>
86
+ );
87
+ }
88
+ ```
89
+
90
+ ## Building Your Dropzone with Folder Support
91
+
92
+ For production use, you'll want to support folder drag-and-drop using modern browser APIs. Here's a complete example:
93
+
94
+ ```tsx
95
+ import { useState } from 'react';
96
+ import { useDropzoneManager } from '@shipstatic/assets';
97
+
98
+ function MyDeployUI() {
99
+ const dropzone = useDropzoneManager();
100
+ const [isDragActive, setIsDragActive] = useState(false);
101
+
102
+ const handleDrop = async (e: React.DragEvent) => {
103
+ e.preventDefault();
104
+ setIsDragActive(false);
105
+
106
+ // Extract files with folder structure preserved
107
+ const files = await extractFilesWithStructure(e.dataTransfer);
108
+ dropzone.processFiles(files);
109
+ };
110
+
111
+ const extractFilesWithStructure = async (
112
+ dataTransfer: DataTransfer
113
+ ): Promise<File[]> => {
114
+ const files: File[] = [];
115
+ const items = dataTransfer.items;
116
+
117
+ if (!items) return Array.from(dataTransfer.files);
118
+
119
+ for (let i = 0; i < items.length; i++) {
120
+ const item = items[i];
121
+ if (item.kind === 'file') {
122
+ await processDataTransferItem(item, files);
123
+ }
124
+ }
125
+
126
+ return files.length > 0 ? files : Array.from(dataTransfer.files);
127
+ };
128
+
129
+ const processDataTransferItem = async (
130
+ item: DataTransferItem,
131
+ files: File[]
132
+ ): Promise<void> => {
133
+ // Try modern File System Access API first (Chrome 86+)
134
+ if (
135
+ globalThis.isSecureContext &&
136
+ typeof (item as any).getAsFileSystemHandle === 'function'
137
+ ) {
138
+ try {
139
+ const handle = await (item as any).getAsFileSystemHandle();
140
+ if (handle) {
141
+ await processFileSystemHandle(handle, files, '');
142
+ return;
143
+ }
144
+ } catch (err) {
145
+ // Fall through to webkit API
146
+ }
147
+ }
148
+
149
+ // Fallback to webkitGetAsEntry (broader browser support)
150
+ const entry = (item as any).webkitGetAsEntry?.();
151
+ if (entry) {
152
+ await processEntry(entry, files, '');
153
+ }
154
+ };
155
+
156
+ const processFileSystemHandle = async (
157
+ handle: any,
158
+ files: File[],
159
+ basePath: string
160
+ ): Promise<void> => {
161
+ if (handle.kind === 'file') {
162
+ const file = await handle.getFile();
163
+ // Set webkitRelativePath for Ship SDK compatibility
164
+ Object.defineProperty(file, 'webkitRelativePath', {
165
+ value: basePath + file.name,
166
+ writable: false,
167
+ enumerable: true,
168
+ configurable: true,
169
+ });
170
+ files.push(file);
171
+ } else if (handle.kind === 'directory') {
172
+ const dirPath = basePath + handle.name + '/';
173
+ for await (const entry of handle.values()) {
174
+ await processFileSystemHandle(entry, files, dirPath);
175
+ }
176
+ }
177
+ };
178
+
179
+ const processEntry = async (
180
+ entry: any,
181
+ files: File[],
182
+ basePath: string
183
+ ): Promise<void> => {
184
+ if (entry.isFile) {
185
+ const file = await new Promise<File>((resolve, reject) => {
186
+ entry.file(resolve, reject);
187
+ });
188
+ // Set webkitRelativePath for Ship SDK compatibility
189
+ Object.defineProperty(file, 'webkitRelativePath', {
190
+ value: basePath + entry.name,
191
+ writable: false,
192
+ enumerable: true,
193
+ configurable: true,
194
+ });
195
+ files.push(file);
196
+ } else if (entry.isDirectory) {
197
+ const dirReader = entry.createReader();
198
+ const entries = await new Promise<any[]>((resolve, reject) => {
199
+ dirReader.readEntries(resolve, reject);
200
+ });
201
+
202
+ for (const childEntry of entries) {
203
+ await processEntry(childEntry, files, basePath + entry.name + '/');
204
+ }
205
+ }
206
+ };
207
+
208
+ return (
209
+ <div
210
+ onDragOver={(e) => { e.preventDefault(); setIsDragActive(true); }}
211
+ onDragLeave={() => setIsDragActive(false)}
212
+ onDrop={handleDrop}
213
+ className={isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
214
+ >
215
+ {dropzone.isProcessing ? (
216
+ <p>Processing {dropzone.files.length} files...</p>
217
+ ) : (
218
+ <p>Drag & drop files or folders here</p>
219
+ )}
220
+
221
+ {dropzone.validationError && (
222
+ <div className="text-red-600">{dropzone.validationError.details}</div>
223
+ )}
224
+ </div>
225
+ );
226
+ }
227
+ ```
228
+
229
+ ### Why This Approach?
230
+
231
+ - ✅ **Preserves folder structure** via `webkitRelativePath`
232
+ - ✅ **Uses modern File System Access API** (no permission prompts in Chrome 86+)
233
+ - ✅ **Fallback to webkit APIs** for broader browser support (Safari, Firefox)
234
+ - ✅ **You control every aspect** of the UI and UX
235
+
236
+ ## API
237
+
238
+ ### `useDropzoneManager(options?)`
239
+
240
+ Main hook for managing dropzone state.
241
+
242
+ **Options:**
243
+
244
+ ```typescript
245
+ interface DropzoneManagerOptions {
246
+ /** Validation configuration (from ship.getConfig()) */
247
+ config?: Partial<ValidationConfig>;
248
+ /** Callback when validation fails */
249
+ onValidationError?: (error: ClientError) => void;
250
+ /** Callback when files are ready for upload */
251
+ onFilesReady?: (files: ProcessedFile[]) => void;
252
+ /** Whether to strip common directory prefix from paths (default: true) */
253
+ stripPrefix?: boolean;
254
+ }
255
+ ```
256
+
257
+ **Returns:**
258
+
259
+ ```typescript
260
+ interface DropzoneManagerReturn {
261
+ /** All processed files with their status */
262
+ files: ProcessedFile[];
263
+ /** Current status text */
264
+ statusText: string;
265
+ /** Whether currently processing files (ZIP extraction, etc.) */
266
+ isProcessing: boolean;
267
+ /** Last validation error if any */
268
+ validationError: ClientError | null;
269
+ /** Whether all valid files have MD5 checksums calculated */
270
+ hasChecksums: boolean;
271
+
272
+ /** Process files from dropzone (resets and replaces existing files) */
273
+ processFiles: (files: File[]) => Promise<void>;
274
+ /** Remove a specific file */
275
+ removeFile: (fileId: string) => void;
276
+ /** Clear all files and reset state */
277
+ clearAll: () => void;
278
+ /** Get only valid files ready for upload */
279
+ getValidFiles: () => ProcessedFile[];
280
+ /** Update upload state for a specific file (status, progress, message) */
281
+ updateFileStatus: (fileId: string, state: {
282
+ status: FileStatus;
283
+ statusMessage?: string;
284
+ progress?: number;
285
+ }) => void;
286
+ }
287
+ ```
288
+
289
+ ## Types
290
+
291
+ ```typescript
292
+ /**
293
+ * ProcessedFile extends StaticFile from @shipstatic/types
294
+ * This means it can be passed directly to ship.deployments.create()
295
+ */
296
+ interface ProcessedFile extends StaticFile {
297
+ // StaticFile properties (SDK compatibility)
298
+ content: File; // File object (required by SDK)
299
+ path: string; // Normalized path (webkitRelativePath or file.name)
300
+ size: number; // File size in bytes
301
+ md5?: string; // Pre-calculated MD5 checksum
302
+
303
+ // ProcessedFile-specific properties (UI functionality)
304
+ id: string; // Unique identifier for React keys
305
+ file: File; // Alias for 'content' (better DX)
306
+ name: string; // File name without path
307
+ type: string; // MIME type
308
+ lastModified: number;
309
+ status: FileStatus;
310
+ statusMessage?: string;
311
+ progress?: number; // Upload progress (0-100)
312
+ }
313
+
314
+ /**
315
+ * ValidationConfig is an alias to ConfigResponse from @shipstatic/types
316
+ * Use ship.getConfig() to get the exact validation limits from the server
317
+ */
318
+ interface ValidationConfig {
319
+ maxFileSize: number; // Default: 5MB
320
+ maxTotalSize: number; // Default: 25MB
321
+ maxFilesCount: number; // Default: 100
322
+ }
323
+
324
+ interface ClientError {
325
+ error: string;
326
+ details: string;
327
+ isClientError: true;
328
+ }
329
+
330
+ type FileStatus =
331
+ | 'pending'
332
+ | 'ready'
333
+ | 'uploading'
334
+ | 'complete'
335
+ | 'processing_error'
336
+ | 'error'
337
+ | 'validation_failed'
338
+ | 'empty_file';
339
+ ```
340
+
341
+ ## Direct Ship SDK Integration
342
+
343
+ **ProcessedFile extends StaticFile** - no conversion needed! Since `ProcessedFile` extends `StaticFile` from `@shipstatic/types`, you can pass the files directly to the Ship SDK:
344
+
345
+ ```typescript
346
+ const validFiles = dropzone.getValidFiles();
347
+
348
+ // ProcessedFile[] IS StaticFile[] - pass directly!
349
+ await ship.deployments.create({ files: validFiles });
350
+ ```
351
+
352
+ ### Type Compatibility
353
+
354
+ ```typescript
355
+ // ✅ This works because ProcessedFile extends StaticFile
356
+ interface ProcessedFile extends StaticFile {
357
+ content: File; // Required by StaticFile
358
+ path: string; // Required by StaticFile
359
+ size: number; // Required by StaticFile
360
+ md5?: string; // Required by StaticFile
361
+
362
+ // Additional UI properties
363
+ id: string;
364
+ file: File; // Alias for 'content' (better DX)
365
+ name: string;
366
+ type: string;
367
+ status: FileStatus;
368
+ // ... etc
369
+ }
370
+ ```
371
+
372
+ **Important**: The dropzone preserves folder structure via `webkitRelativePath` and processes paths with `stripCommonPrefix` automatically. The `path` property is always deployment-ready.
373
+
374
+ ## Architecture Decisions
375
+
376
+ ### Why Not Abstract?
377
+
378
+ This package was extracted from the `web/drop` application and is purpose-built for Ship SDK. Key decisions:
379
+
380
+ **1. Tightly Coupled to Ship SDK Requirements**
381
+ - MD5 calculation is **mandatory** (Ship SDK requires it for integrity checks)
382
+ - Common prefix stripping is **mandatory** (ensures clean deployment paths)
383
+ - Folder structure preservation is **mandatory** (via `webkitRelativePath`)
384
+ - These aren't optional features - they're essential for Ship deployments
385
+
386
+ **2. Loosely Coupled Integration Pattern**
387
+ Following industry standards (Firebase hooks, Supabase utilities), we chose:
388
+ - ✅ **Decoupled**: No Ship SDK dependency in this package
389
+ - ✅ **Simple**: Direct `File[]` input/output
390
+ - ✅ **Testable**: No mocking of Ship SDK needed
391
+ - ✅ **Flexible**: Host app controls WHEN to deploy
392
+
393
+ Instead of:
394
+ - ❌ Passing Ship SDK instance to useDropzoneManager
395
+ - ❌ React Context provider pattern
396
+ - ❌ Global configuration singleton
397
+
398
+ **3. Type System Integration**
399
+
400
+ ProcessedFile extends StaticFile from `@shipstatic/types` - the single source of truth for Ship SDK types:
401
+
402
+ ```
403
+ File[] → ProcessedFile[] (which IS StaticFile[]) → ship.deployments.create()
404
+ ```
405
+
406
+ No conversion needed. ProcessedFile adds UI-specific properties (id, name, status, progress) to StaticFile's base properties (content, path, size, md5).
407
+
408
+ **4. No UI Components**
409
+
410
+ We deliberately don't provide dropzone UI components because:
411
+ - Generic dropzone libraries (like `react-dropzone`) don't support folder structure preservation
412
+ - Proper folder drag-and-drop requires modern browser APIs that need custom implementation
413
+ - Your deployment UI is unique to your application
414
+ - Providing a component that "works but loses paths" would be misleading
415
+
416
+ ### Error Handling
417
+
418
+ MD5 calculation failures are properly handled:
419
+ - Files with failed MD5 calculation are marked with `status: PROCESSING_ERROR`
420
+ - The `statusMessage` contains the specific error details
421
+ - These files are excluded from `getValidFiles()` and cannot be deployed
422
+ - No silent failures - all errors are visible to users
423
+
424
+ ### Security
425
+
426
+ **Path Sanitization**: ZIP extraction includes defense-in-depth protection against directory traversal attacks:
427
+ - Normalizes all file paths by removing `..`, `.`, and empty segments
428
+ - Prevents traversal above the root directory
429
+ - Converts absolute paths to relative paths
430
+ - Skips files that resolve to empty paths after normalization
431
+ - Comprehensive test coverage for various attack vectors
432
+
433
+ 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.
434
+
435
+ **Concurrency Protection**: The `processFiles` function includes built-in race condition protection:
436
+ - Uses a synchronous ref guard to prevent concurrent processing
437
+ - Automatically ignores duplicate calls while processing is in progress
438
+ - Logs warnings when concurrent calls are detected
439
+ - Ensures the processing flag is always cleared, even on errors
440
+ - Makes the hook robust regardless of UI implementation
441
+
442
+ ## License
443
+
444
+ MIT