@visulima/storage 1.0.0-alpha.1

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE.md +1189 -0
  3. package/README.md +561 -0
  4. package/package.json +286 -0
package/README.md ADDED
@@ -0,0 +1,561 @@
1
+ <div align="center">
2
+ <h3>Visulima upload</h3>
3
+ <p>Visulima upload copies files to a web-accessible location and provides a consistent way to get the URLs that correspond to those files.</p>
4
+ <p>Visulima upload can also resize, crop and autorotate uploaded images.</p>
5
+ <p>Visulima upload includes S3-based, Azure-based, GCS-based and local filesystem-based backends and you may supply others.</p>
6
+ </div>
7
+
8
+ <br />
9
+
10
+ <div align="center">
11
+
12
+ [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url]
13
+
14
+ </div>
15
+
16
+ <div align="center">
17
+ <sub>Built with ❤︎ by <a href="https://twitter.com/_prisis_">Daniel Bannert</a></sub>
18
+ </div>
19
+
20
+ ## Features
21
+
22
+ - **Multiple Upload Handlers**: Multipart (form-based), REST (direct binary), and TUS (resumable) uploads
23
+ - **Chunked Uploads**: REST handler supports client-side chunked uploads for large files
24
+ - **Automatic Retry**: Built-in retry mechanism with exponential backoff for all storage backends (S3, Azure, GCS, Vercel Blob, Netlify Blob)
25
+ - Parent directories are created automatically as needed (like S3 and Azure)
26
+ - Content types are inferred from file extensions (like the filesystem)
27
+ - Files are by default marked as readable via the web (like a filesystem + web server)
28
+ - Images can be automatically scaled to multiple sizes
29
+ - Images can be cropped
30
+ - Images are automatically rotated if necessary for proper display on the web (i.e. iPhone photos with rotation hints are right side up)
31
+ - Image width, image height and correct file extension are made available to the developer
32
+ - Non-image files are also supported
33
+ - Web access to files can be disabled and reenabled
34
+ - GIF is supported, including animation, with full support for scaling and cropping
35
+ - Batch operations: Delete multiple files in a single request
36
+ - On fire about minimizing file sizes for your resized images? You can plug in `imagemin` and compatible tools using the `postprocessors` option.
37
+
38
+ ## Installation
39
+
40
+ ```sh
41
+ npm install @visulima/upload
42
+ ```
43
+
44
+ ```sh
45
+ yarn add @visulima/upload
46
+ ```
47
+
48
+ ```sh
49
+ pnpm add @visulima/upload
50
+ ```
51
+
52
+ ## Install requirements peer storage
53
+
54
+ ### AWS S3
55
+
56
+ ```sh
57
+ npm install @aws-sdk/client-s3 @aws-sdk/credential-providers @aws-sdk/s3-request-presigner @aws-sdk/signature-v4-crt aws-crt @aws-sdk/types
58
+ ```
59
+
60
+ ```sh
61
+ yarn add @aws-sdk/client-s3 @aws-sdk/credential-providers @aws-sdk/s3-request-presigner @aws-sdk/signature-v4-crt aws-crt @aws-sdk/types
62
+ ```
63
+
64
+ ```sh
65
+ pnpm add @aws-sdk/client-s3 @aws-sdk/credential-providers @aws-sdk/s3-request-presigner @aws-sdk/signature-v4-crt aws-crt @aws-sdk/types
66
+ ```
67
+
68
+ ### Azure Blob Storage
69
+
70
+ ```sh
71
+ npm install @azure/storage-blob
72
+ ```
73
+
74
+ ```sh
75
+ yarn add @azure/storage-blob
76
+ ```
77
+
78
+ ```sh
79
+ pnpm add @azure/storage-blob
80
+ ```
81
+
82
+ > Note: If you hit this error: "TypeError: Expected signal to be an instanceof AbortSignal" [#784](https://github.com/node-fetch/node-fetch/issues/784) you need to install node-fetch 4.0.0-beta or higher.
83
+
84
+ ### Google Cloud Storage
85
+
86
+ ```sh
87
+ npm install @google-cloud/storage node-fetch gaxios
88
+ ```
89
+
90
+ ```sh
91
+ yarn add @google-cloud/storage node-fetch gaxios
92
+ ```
93
+
94
+ ```sh
95
+ pnpm add @google-cloud/storage node-fetch gaxios
96
+ ```
97
+
98
+ ## Caching
99
+
100
+ The storage package supports caching to improve performance and reduce API calls. You provide your own cache implementation that follows the simple `Cache` interface.
101
+
102
+ ### LRU Cache
103
+
104
+ Use the built-in LRU cache for simple in-memory caching:
105
+
106
+ ```typescript
107
+ import { LRUCache } from "lru-cache";
108
+ import { DiskStorage } from "@visulima/storage";
109
+
110
+ const cache = new LRUCache({
111
+ max: 1000, // Maximum number of items
112
+ ttl: 3600000, // 1 hour in milliseconds
113
+ });
114
+
115
+ const storage = new DiskStorage({
116
+ directory: "/uploads",
117
+ cache,
118
+ });
119
+ ```
120
+
121
+ ### Custom Cache Implementation
122
+
123
+ Implement the `Cache` interface for any cache provider:
124
+
125
+ ```typescript
126
+ import { DiskStorage, type Cache } from "@visulima/storage";
127
+ import { Redis } from "ioredis";
128
+
129
+ // Custom Redis cache implementation
130
+ class RedisCache implements Cache<string, any> {
131
+ constructor(private redis: Redis) {}
132
+
133
+ async get(key: string): Promise<any | undefined> {
134
+ const value = await this.redis.get(key);
135
+ return value ? JSON.parse(value) : undefined;
136
+ }
137
+
138
+ async set(key: string, value: any): Promise<boolean> {
139
+ await this.redis.set(key, JSON.stringify(value));
140
+ return true;
141
+ }
142
+
143
+ async delete(key: string): Promise<boolean> {
144
+ await this.redis.del(key);
145
+ return true;
146
+ }
147
+
148
+ async clear(): Promise<void> {
149
+ await this.redis.flushall();
150
+ }
151
+
152
+ async has(key: string): Promise<boolean> {
153
+ const exists = await this.redis.exists(key);
154
+ return exists === 1;
155
+ }
156
+ }
157
+
158
+ const redis = new Redis();
159
+ const cache = new RedisCache(redis);
160
+
161
+ const storage = new DiskStorage({
162
+ directory: "/uploads",
163
+ cache,
164
+ });
165
+ ```
166
+
167
+ ### BentoCache Integration
168
+
169
+ For advanced multi-tier caching, use BentoCache with the adapter:
170
+
171
+ ```typescript
172
+ import { BentoCache, bentostore } from "bentocache";
173
+ import { memoryDriver } from "bentocache/drivers/memory";
174
+ import { redisDriver } from "bentocache/drivers/redis";
175
+ import { BentoCacheAdapter } from "@visulima/storage/utils/cache";
176
+
177
+ const bento = new BentoCache({
178
+ default: "storage",
179
+ stores: {
180
+ storage: bentostore()
181
+ .useL1Layer(memoryDriver({ maxSize: "10mb" }))
182
+ .useL2Layer(
183
+ redisDriver({
184
+ connection: { host: "127.0.0.1", port: 6379 },
185
+ }),
186
+ ),
187
+ },
188
+ });
189
+
190
+ const cache = new BentoCacheAdapter({
191
+ bento,
192
+ namespace: "storage",
193
+ defaultTtl: 3600000, // 1 hour
194
+ });
195
+
196
+ const storage = new DiskStorage({
197
+ directory: "/uploads",
198
+ cache,
199
+ });
200
+ ```
201
+
202
+ ### Transformer Caching
203
+
204
+ ```typescript
205
+ import { MediaTransformer } from "@visulima/upload/transformer";
206
+ import ImageTransformer from "@visulima/upload/transformer/image";
207
+ import VideoTransformer from "@visulima/upload/transformer/video";
208
+ import { LRUCache } from "lru-cache";
209
+
210
+ const transformer = new MediaTransformer(storage, {
211
+ cache: new LRUCache({ max: 100, ttl: 3600000 }),
212
+ ImageTransformer: ImageTransformer,
213
+ VideoTransformer: VideoTransformer,
214
+ });
215
+ ```
216
+
217
+ ### Cache Interface
218
+
219
+ Any cache implementation must implement this interface:
220
+
221
+ ```typescript
222
+ interface Cache<K = string, V = any> {
223
+ get(key: K): V | undefined | Promise<V | undefined>;
224
+ set(key: K, value: V, options?: { ttl?: number }): boolean | Promise<boolean>;
225
+ delete(key: K): boolean | Promise<boolean>;
226
+ clear(): void | Promise<void>;
227
+ has(key: K): boolean | Promise<boolean>;
228
+ }
229
+ ```
230
+
231
+ For more information, see the [BentoCache documentation](https://bentocache.dev/docs/introduction).
232
+
233
+ ## Retry Mechanism
234
+
235
+ All storage backends (AWS S3, Azure, GCS, Vercel Blob, Netlify Blob) include automatic retry logic for transient failures. This ensures your uploads and file operations are resilient to network issues, rate limits, and temporary service unavailability.
236
+
237
+ ### Default Behavior
238
+
239
+ By default, storage operations will retry up to 3 times with exponential backoff:
240
+
241
+ - Initial delay: 1 second
242
+ - Backoff multiplier: 2x (delays double each retry: 1s → 2s → 4s)
243
+ - Maximum delay: 30 seconds
244
+ - Retryable status codes: 408, 429, 500, 502, 503, 504
245
+
246
+ ### Basic Configuration
247
+
248
+ Configure retry behavior when creating your storage instance:
249
+
250
+ ```typescript
251
+ import { S3Storage } from "@visulima/storage";
252
+
253
+ const storage = new S3Storage({
254
+ bucket: "my-bucket",
255
+ region: "us-east-1",
256
+ retryConfig: {
257
+ maxRetries: 5,
258
+ initialDelay: 2000,
259
+ backoffMultiplier: 1.5,
260
+ maxDelay: 60_000,
261
+ },
262
+ });
263
+ ```
264
+
265
+ ### Advanced Configuration
266
+
267
+ Customize retry logic with backend-specific error detection:
268
+
269
+ ```typescript
270
+ import { AzureStorage } from "@visulima/storage";
271
+
272
+ const storage = new AzureStorage({
273
+ containerName: "uploads",
274
+ accountName: "myaccount",
275
+ accountKey: "mykey",
276
+ retryConfig: {
277
+ maxRetries: 3,
278
+ initialDelay: 1000,
279
+ backoffMultiplier: 2,
280
+ maxDelay: 30_000,
281
+ retryableStatusCodes: [408, 429, 500, 502, 503, 504],
282
+ shouldRetry: (error: unknown) => {
283
+ // Custom retry logic
284
+ if (error instanceof Error) {
285
+ const errorCode = (error as any).code;
286
+
287
+ // Retry on network errors
288
+ if (errorCode === "ECONNRESET" || errorCode === "ETIMEDOUT") {
289
+ return true;
290
+ }
291
+ }
292
+
293
+ // Retry on specific HTTP status codes
294
+ if ((error as any).statusCode && [429, 503].includes((error as any).statusCode)) {
295
+ return true;
296
+ }
297
+
298
+ return false;
299
+ },
300
+ },
301
+ });
302
+ ```
303
+
304
+ ### Retry Configuration Options
305
+
306
+ ```typescript
307
+ interface RetryConfig {
308
+ /** Maximum number of retry attempts (default: 3) */
309
+ maxRetries?: number;
310
+
311
+ /** Initial delay in milliseconds before first retry (default: 1000) */
312
+ initialDelay?: number;
313
+
314
+ /** Multiplier for exponential backoff (default: 2) */
315
+ backoffMultiplier?: number;
316
+
317
+ /** Maximum delay in milliseconds between retries (default: 30000) */
318
+ maxDelay?: number;
319
+
320
+ /** HTTP status codes that should trigger a retry (default: [408, 429, 500, 502, 503, 504]) */
321
+ retryableStatusCodes?: number[];
322
+
323
+ /** Custom function to determine if an error should be retried */
324
+ shouldRetry?: (error: unknown) => boolean;
325
+
326
+ /** Custom function to calculate delay for a specific retry attempt */
327
+ calculateDelay?: (attempt: number, error: unknown) => number | undefined;
328
+ }
329
+ ```
330
+
331
+ ### Using Retry Utilities Directly
332
+
333
+ You can also use the retry utilities for custom operations:
334
+
335
+ ```typescript
336
+ import { retry, createRetryWrapper, isRetryableError } from "@visulima/storage";
337
+
338
+ // One-off retry
339
+ const result = await retry(
340
+ async () => {
341
+ // Your operation here
342
+ return await someOperation();
343
+ },
344
+ {
345
+ maxRetries: 3,
346
+ initialDelay: 1000,
347
+ },
348
+ );
349
+
350
+ // Create a reusable retry wrapper
351
+ const retryWrapper = createRetryWrapper({
352
+ maxRetries: 5,
353
+ initialDelay: 2000,
354
+ });
355
+
356
+ const result = await retryWrapper(async () => {
357
+ return await someOperation();
358
+ });
359
+
360
+ // Check if an error is retryable
361
+ if (isRetryableError(error)) {
362
+ // Handle retryable error
363
+ }
364
+ ```
365
+
366
+ ### Supported Error Types
367
+
368
+ The retry mechanism automatically handles:
369
+
370
+ - **Network errors**: `ECONNRESET`, `ETIMEDOUT`, `ENOTFOUND`, `ECONNREFUSED`, `EAI_AGAIN`
371
+ - **AWS SDK errors**: Server faults, retryable status codes, SDK v2/v3 error formats
372
+ - **Azure Storage errors**: HTTP status codes, network connection issues
373
+ - **HTTP errors**: 408 (Request Timeout), 429 (Too Many Requests), 5xx (Server Errors)
374
+
375
+ ### Examples by Storage Backend
376
+
377
+ #### AWS S3
378
+
379
+ ```typescript
380
+ import { S3Storage } from "@visulima/storage";
381
+
382
+ const storage = new S3Storage({
383
+ bucket: "my-bucket",
384
+ region: "us-east-1",
385
+ retryConfig: {
386
+ maxRetries: 3,
387
+ // AWS SDK errors are automatically detected
388
+ },
389
+ });
390
+ ```
391
+
392
+ #### Azure Blob Storage
393
+
394
+ ```typescript
395
+ import { AzureStorage } from "@visulima/storage";
396
+
397
+ const storage = new AzureStorage({
398
+ containerName: "uploads",
399
+ accountName: "myaccount",
400
+ accountKey: "mykey",
401
+ retryConfig: {
402
+ maxRetries: 5,
403
+ initialDelay: 2000,
404
+ },
405
+ });
406
+ ```
407
+
408
+ #### Google Cloud Storage
409
+
410
+ GCS already has built-in retry support via `gaxios`. The retry mechanism works alongside GCS's native retry logic:
411
+
412
+ ```typescript
413
+ import { GCStorage } from "@visulima/storage";
414
+
415
+ const storage = new GCStorage({
416
+ bucket: "my-bucket",
417
+ projectId: "my-project",
418
+ retryConfig: {
419
+ maxRetries: 3,
420
+ // Works with GCS's existing retryOptions
421
+ },
422
+ });
423
+ ```
424
+
425
+ #### Vercel Blob
426
+
427
+ ```typescript
428
+ import { VercelBlobStorage } from "@visulima/storage";
429
+
430
+ const storage = new VercelBlobStorage({
431
+ token: process.env.BLOB_READ_WRITE_TOKEN,
432
+ retryConfig: {
433
+ maxRetries: 3,
434
+ retryableStatusCodes: [408, 429, 500, 502, 503, 504],
435
+ },
436
+ });
437
+ ```
438
+
439
+ #### Netlify Blob
440
+
441
+ ```typescript
442
+ import { NetlifyBlobStorage } from "@visulima/storage";
443
+
444
+ const storage = new NetlifyBlobStorage({
445
+ storeName: "uploads",
446
+ retryConfig: {
447
+ maxRetries: 3,
448
+ },
449
+ });
450
+ ```
451
+
452
+ ## Chunked Uploads
453
+
454
+ The REST handler supports client-side chunked uploads for large files. This allows you to upload files in smaller pieces, reducing memory usage and enabling resumable uploads.
455
+
456
+ ### Initializing a Chunked Upload
457
+
458
+ ```typescript
459
+ import { Rest } from "@visulima/upload/handler/rest";
460
+
461
+ const rest = new Rest({ storage });
462
+
463
+ // Initialize chunked upload
464
+ const initResponse = await fetch("/files", {
465
+ method: "POST",
466
+ headers: {
467
+ "X-Chunked-Upload": "true",
468
+ "X-Total-Size": "10485760", // Total file size in bytes
469
+ "Content-Length": "0",
470
+ "Content-Type": "application/octet-stream",
471
+ },
472
+ });
473
+
474
+ const { id } = await initResponse.json();
475
+ // id is the upload session ID
476
+ ```
477
+
478
+ ### Uploading Chunks
479
+
480
+ ```typescript
481
+ // Upload chunk 1 (bytes 0-524288)
482
+ await fetch(`/files/${id}`, {
483
+ method: "PATCH",
484
+ headers: {
485
+ "X-Chunk-Offset": "0",
486
+ "Content-Length": "524288",
487
+ "Content-Type": "application/octet-stream",
488
+ },
489
+ body: chunk1,
490
+ });
491
+
492
+ // Upload chunk 2 (bytes 524288-1048576) - can be out of order
493
+ await fetch(`/files/${id}`, {
494
+ method: "PATCH",
495
+ headers: {
496
+ "X-Chunk-Offset": "524288",
497
+ "Content-Length": "524288",
498
+ "Content-Type": "application/octet-stream",
499
+ },
500
+ body: chunk2,
501
+ });
502
+ ```
503
+
504
+ ### Checking Upload Progress
505
+
506
+ ```typescript
507
+ // Check upload status
508
+ const statusResponse = await fetch(`/files/${id}`, {
509
+ method: "HEAD",
510
+ });
511
+
512
+ const offset = statusResponse.headers.get("X-Upload-Offset");
513
+ const complete = statusResponse.headers.get("X-Upload-Complete");
514
+ const chunks = JSON.parse(statusResponse.headers.get("X-Received-Chunks") || "[]");
515
+
516
+ console.log(`Uploaded: ${offset} bytes, Complete: ${complete}`);
517
+ ```
518
+
519
+ ### Features
520
+
521
+ - **Out-of-Order Chunks**: Chunks can be uploaded in any order
522
+ - **Idempotency**: Duplicate chunks are safely ignored
523
+ - **Resumable**: Check progress and resume from last uploaded chunk
524
+ - **Progress Tracking**: Real-time upload progress via HEAD requests
525
+ - **Chunk Size Limits**: Maximum 100MB per chunk (configurable)
526
+
527
+ ### Response Headers
528
+
529
+ - `X-Upload-ID`: Upload session ID (returned on initialization)
530
+ - `X-Chunked-Upload`: Indicates chunked upload mode
531
+ - `X-Upload-Offset`: Current upload offset in bytes
532
+ - `X-Upload-Complete`: "true" when upload is complete, "false" otherwise
533
+ - `X-Received-Chunks`: JSON array of received chunks `[{ offset, length }]`
534
+
535
+ ## Supported Node.js Versions
536
+
537
+ Libraries in this ecosystem make the best effort to track
538
+ [Node.js’ release schedule](https://github.com/nodejs/release#release-schedule). Here’s [a
539
+ post on why we think this is important](https://medium.com/the-node-js-collection/maintainers-should-consider-following-node-js-release-schedule-ab08ed4de71a).
540
+
541
+ ## Contributing
542
+
543
+ If you would like to help take a look at the [list of issues](https://github.com/visulima/visulima/issues) and check our [Contributing](.github/CONTRIBUTING.md) guild.
544
+
545
+ > **Note:** please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
546
+
547
+ ## Credits
548
+
549
+ - [Daniel Bannert](https://github.com/prisis)
550
+ - [All Contributors](https://github.com/visulima/visulima/graphs/contributors)
551
+
552
+ ## License
553
+
554
+ The visulima uploads is open-sourced software licensed under the [MIT][license-url]
555
+
556
+ [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript
557
+ [typescript-url]: "typescript"
558
+ [license-image]: https://img.shields.io/npm/l/@visulima/upload?color=blueviolet&style=for-the-badge
559
+ [license-url]: LICENSE.md "license"
560
+ [npm-image]: https://img.shields.io/npm/v/@visulima/upload/latest.svg?style=for-the-badge&logo=npm
561
+ [npm-url]: https://www.npmjs.com/package/@visulima/upload/v/latest "npm"