@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 +444 -0
- package/dist/index.cjs +12548 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +200 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +12533 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
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
|