@nextlyhq/storage-s3 0.0.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,470 @@
1
+ import { S3Client, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
2
+ import { Upload } from '@aws-sdk/lib-storage';
3
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
4
+
5
+ // src/adapter.ts
6
+ var S3StorageAdapter = class {
7
+ /**
8
+ * Create a new S3 storage adapter.
9
+ *
10
+ * @param config - S3 storage configuration
11
+ * @throws Error if bucket or region is not provided
12
+ */
13
+ constructor(config) {
14
+ this.config = config;
15
+ if (!config.bucket) {
16
+ throw new Error("@nextly/storage-s3: bucket is required");
17
+ }
18
+ if (!config.region) {
19
+ throw new Error("@nextly/storage-s3: region is required");
20
+ }
21
+ this.isR2 = config.endpoint?.includes("r2.cloudflarestorage.com") ?? false;
22
+ this.resolvedConfig = {
23
+ bucket: config.bucket,
24
+ region: config.region,
25
+ endpoint: config.endpoint,
26
+ forcePathStyle: config.forcePathStyle ?? false,
27
+ acl: config.acl ?? "private",
28
+ publicUrl: config.publicUrl,
29
+ cacheControl: config.cacheControl ?? "public, max-age=31536000",
30
+ contentDisposition: config.contentDisposition,
31
+ signedUrlExpiresIn: config.signedUrlExpiresIn ?? 3600
32
+ };
33
+ const credentials = this.buildCredentials();
34
+ this.client = new S3Client({
35
+ region: config.region,
36
+ endpoint: config.endpoint,
37
+ credentials,
38
+ forcePathStyle: this.resolvedConfig.forcePathStyle,
39
+ ...config.config
40
+ });
41
+ }
42
+ client;
43
+ resolvedConfig;
44
+ isR2;
45
+ /**
46
+ * Build AWS credentials from config.
47
+ * Supports explicit credentials or falls back to SDK default chain.
48
+ */
49
+ buildCredentials() {
50
+ if (this.config.accessKeyId && this.config.secretAccessKey) {
51
+ return {
52
+ accessKeyId: this.config.accessKeyId,
53
+ secretAccessKey: this.config.secretAccessKey
54
+ };
55
+ }
56
+ if (this.config.config?.credentials) {
57
+ return void 0;
58
+ }
59
+ return void 0;
60
+ }
61
+ // ============================================================
62
+ // Core IStorageAdapter Methods
63
+ // ============================================================
64
+ /**
65
+ * Upload file to S3.
66
+ *
67
+ * Uses AWS SDK v3's Upload class from @aws-sdk/lib-storage which:
68
+ * - Automatically handles multipart upload for large files (>5MB)
69
+ * - Provides progress tracking capability
70
+ * - Includes retry logic
71
+ * - Optimizes upload performance
72
+ *
73
+ * @param buffer - File content as Buffer
74
+ * @param options - Upload options (filename, mimeType, folder, collection)
75
+ * @returns Upload result with URL and storage path
76
+ */
77
+ async upload(buffer, options) {
78
+ const key = this.generateKey(options.filename, options.folder);
79
+ const uploadParams = {
80
+ Bucket: this.resolvedConfig.bucket,
81
+ Key: key,
82
+ Body: buffer,
83
+ ContentType: options.contentType || options.mimeType,
84
+ CacheControl: this.resolvedConfig.cacheControl
85
+ };
86
+ if (!this.isR2) {
87
+ uploadParams.ACL = this.resolvedConfig.acl;
88
+ }
89
+ const disposition = options.contentDisposition ?? this.resolvedConfig.contentDisposition;
90
+ if (disposition) {
91
+ const filename = this.sanitizeFilename(options.filename);
92
+ uploadParams.ContentDisposition = disposition === "attachment" ? `attachment; filename="${filename}"` : "inline";
93
+ }
94
+ uploadParams.Metadata = {
95
+ "original-filename": options.filename
96
+ };
97
+ const upload = new Upload({
98
+ client: this.client,
99
+ params: uploadParams
100
+ });
101
+ await upload.done();
102
+ return {
103
+ url: this.getPublicUrl(key),
104
+ path: key
105
+ };
106
+ }
107
+ /**
108
+ * Delete file from S3.
109
+ *
110
+ * @param filePath - Storage path/key to delete
111
+ */
112
+ async delete(filePath) {
113
+ const command = new DeleteObjectCommand({
114
+ Bucket: this.resolvedConfig.bucket,
115
+ Key: filePath
116
+ });
117
+ await this.client.send(command);
118
+ }
119
+ /**
120
+ * Bulk delete files from S3 using a single API call per 1000 keys.
121
+ *
122
+ * Uses AWS SDK v3's DeleteObjectsCommand which supports up to 1000 keys per
123
+ * request. Automatically batches larger arrays and collects per-key results.
124
+ *
125
+ * @param filePaths - Storage paths/keys to delete
126
+ * @returns Object with arrays of successful and failed deletions
127
+ */
128
+ async bulkDelete(filePaths) {
129
+ const successful = [];
130
+ const failed = [];
131
+ const maxBatchSize = 1e3;
132
+ for (let i = 0; i < filePaths.length; i += maxBatchSize) {
133
+ const batch = filePaths.slice(i, i + maxBatchSize);
134
+ const command = new DeleteObjectsCommand({
135
+ Bucket: this.resolvedConfig.bucket,
136
+ Delete: {
137
+ Objects: batch.map((key) => ({ Key: key })),
138
+ Quiet: false
139
+ }
140
+ });
141
+ const response = await this.client.send(command);
142
+ if (response.Errors && response.Errors.length > 0) {
143
+ for (const err of response.Errors) {
144
+ if (err.Key) {
145
+ failed.push({
146
+ filePath: err.Key,
147
+ error: err.Message ?? "Unknown S3 delete error"
148
+ });
149
+ }
150
+ }
151
+ }
152
+ if (response.Deleted) {
153
+ for (const del of response.Deleted) {
154
+ if (del.Key) {
155
+ successful.push(del.Key);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ return { successful, failed };
161
+ }
162
+ /**
163
+ * Check if file exists in S3.
164
+ *
165
+ * Uses HeadObject command which is more efficient than GetObject
166
+ * for existence checks (doesn't download the file).
167
+ *
168
+ * @param filePath - Storage path/key to check
169
+ * @returns true if file exists, false otherwise
170
+ */
171
+ async exists(filePath) {
172
+ try {
173
+ const command = new HeadObjectCommand({
174
+ Bucket: this.resolvedConfig.bucket,
175
+ Key: filePath
176
+ });
177
+ await this.client.send(command);
178
+ return true;
179
+ } catch (error) {
180
+ if (this.isNotFoundError(error)) {
181
+ return false;
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+ /**
187
+ * Get public URL for S3 file.
188
+ *
189
+ * Priority order:
190
+ * 1. Custom publicUrl (CDN or custom domain) if configured
191
+ * 2. Standard S3 URL based on region and bucket
192
+ *
193
+ * For R2: Requires publicUrl configuration (R2 has no default public URLs).
194
+ *
195
+ * @param filePath - Storage path/key
196
+ * @returns Public URL to access the file
197
+ * @throws Error if R2 is used without publicUrl configuration
198
+ */
199
+ getPublicUrl(filePath) {
200
+ if (this.resolvedConfig.publicUrl) {
201
+ const baseUrl = this.resolvedConfig.publicUrl.replace(/\/$/, "");
202
+ return `${baseUrl}/${filePath}`;
203
+ }
204
+ if (this.isR2) {
205
+ throw new Error(
206
+ "@nextly/storage-s3: Cloudflare R2 requires publicUrl configuration.\n\nR2 does not have default public URLs like AWS S3. Configure one of:\n1. Public bucket URL: publicUrl: 'https://pub-xxx.r2.dev'\n2. Custom domain: publicUrl: 'https://cdn.example.com'\n\nSet up public access in the Cloudflare R2 dashboard."
207
+ );
208
+ }
209
+ return `https://${this.resolvedConfig.bucket}.s3.${this.resolvedConfig.region}.amazonaws.com/${filePath}`;
210
+ }
211
+ /**
212
+ * Get storage type identifier.
213
+ *
214
+ * Returns "s3" for all S3-compatible services (AWS S3, R2, MinIO, etc.)
215
+ * as they all use the S3 API.
216
+ */
217
+ getType() {
218
+ return "s3";
219
+ }
220
+ // ============================================================
221
+ // Optional IStorageAdapter Methods
222
+ // ============================================================
223
+ /**
224
+ * Get adapter info including capabilities.
225
+ *
226
+ * @returns Adapter info with type, name, and capability flags
227
+ */
228
+ getInfo() {
229
+ return {
230
+ type: "s3",
231
+ name: "S3StorageAdapter",
232
+ supportsSignedUrls: true,
233
+ supportsClientUploads: true
234
+ };
235
+ }
236
+ /**
237
+ * Get file metadata from S3.
238
+ *
239
+ * Retrieves file information including size, content type, and timestamps
240
+ * using the HeadObject command.
241
+ *
242
+ * @param filePath - Storage path/key
243
+ * @returns File metadata or null if file not found
244
+ */
245
+ async getMetadata(filePath) {
246
+ try {
247
+ const command = new HeadObjectCommand({
248
+ Bucket: this.resolvedConfig.bucket,
249
+ Key: filePath
250
+ });
251
+ const response = await this.client.send(command);
252
+ const filename = filePath.split("/").pop() || filePath;
253
+ return {
254
+ id: filePath,
255
+ filename,
256
+ originalFilename: response.Metadata?.["original-filename"] || filename,
257
+ mimeType: response.ContentType || "application/octet-stream",
258
+ size: response.ContentLength || 0,
259
+ url: this.getPublicUrl(filePath),
260
+ createdAt: response.LastModified?.toISOString() || (/* @__PURE__ */ new Date()).toISOString()
261
+ };
262
+ } catch (error) {
263
+ if (this.isNotFoundError(error)) {
264
+ return null;
265
+ }
266
+ throw error;
267
+ }
268
+ }
269
+ /**
270
+ * Generate signed URL for temporary private file access.
271
+ *
272
+ * Creates a pre-signed GetObject URL that grants temporary read access
273
+ * to private files. Useful for serving files from private buckets.
274
+ *
275
+ * @param filePath - Storage path/key
276
+ * @param expiresIn - URL validity duration in seconds (default: 3600)
277
+ * @returns Pre-signed URL for downloading the file
278
+ */
279
+ async getSignedUrl(filePath, expiresIn) {
280
+ const command = new GetObjectCommand({
281
+ Bucket: this.resolvedConfig.bucket,
282
+ Key: filePath
283
+ });
284
+ return getSignedUrl(this.client, command, {
285
+ expiresIn: expiresIn ?? this.resolvedConfig.signedUrlExpiresIn
286
+ });
287
+ }
288
+ /**
289
+ * Generate pre-signed URL for client-side uploads.
290
+ *
291
+ * Creates a pre-signed PutObject URL that allows direct uploads from
292
+ * the browser to S3, bypassing server-side upload limits (e.g., Vercel's 4.5MB).
293
+ *
294
+ * @param key - Storage path/key for the upload
295
+ * @param mimeType - MIME type of the file being uploaded
296
+ * @param expiresIn - URL validity duration in seconds (default: 3600)
297
+ * @returns Client upload data with URL, method, and headers
298
+ */
299
+ async getPresignedUploadUrl(key, mimeType, expiresIn) {
300
+ const expiration = expiresIn ?? this.resolvedConfig.signedUrlExpiresIn;
301
+ const commandParams = {
302
+ Bucket: this.resolvedConfig.bucket,
303
+ Key: key,
304
+ ContentType: mimeType,
305
+ CacheControl: this.resolvedConfig.cacheControl
306
+ };
307
+ if (!this.isR2) {
308
+ commandParams.ACL = this.resolvedConfig.acl;
309
+ }
310
+ const command = new PutObjectCommand(commandParams);
311
+ const uploadUrl = await getSignedUrl(this.client, command, {
312
+ expiresIn: expiration
313
+ });
314
+ return {
315
+ uploadUrl,
316
+ path: key,
317
+ method: "PUT",
318
+ headers: {
319
+ "Content-Type": mimeType
320
+ },
321
+ expiresAt: new Date(Date.now() + expiration * 1e3)
322
+ };
323
+ }
324
+ // ============================================================
325
+ // Helper Methods
326
+ // ============================================================
327
+ /**
328
+ * Generate a unique storage key with date-based prefix.
329
+ *
330
+ * Creates keys in format: {folder}/{year}/{month}/{uuid}-{sanitized-filename}
331
+ *
332
+ * @param filename - Original filename (will be sanitized)
333
+ * @param folder - Optional folder/prefix for organizing uploads
334
+ * @returns Generated storage key
335
+ */
336
+ generateKey(filename, folder) {
337
+ const sanitized = this.sanitizeFilename(filename);
338
+ const uuid = crypto.randomUUID();
339
+ const date = /* @__PURE__ */ new Date();
340
+ const year = date.getFullYear();
341
+ const month = String(date.getMonth() + 1).padStart(2, "0");
342
+ const prefix = folder ? `${folder}/${year}/${month}` : `${year}/${month}`;
343
+ return `${prefix}/${uuid}-${sanitized}`;
344
+ }
345
+ /**
346
+ * Sanitize filename to prevent directory traversal and S3 key issues.
347
+ *
348
+ * @param filename - Original filename
349
+ * @returns Sanitized filename safe for S3 keys
350
+ */
351
+ sanitizeFilename(filename) {
352
+ const basename = filename.split(/[/\\]/).pop() || filename;
353
+ return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
354
+ }
355
+ /**
356
+ * Check if an error is a "not found" error from S3.
357
+ *
358
+ * @param error - Error to check
359
+ * @returns true if error indicates file not found
360
+ */
361
+ isNotFoundError(error) {
362
+ if (error && typeof error === "object") {
363
+ const e = error;
364
+ return e.name === "NotFound" || e.$metadata?.httpStatusCode === 404;
365
+ }
366
+ return false;
367
+ }
368
+ // ============================================================
369
+ // Public Accessors
370
+ // ============================================================
371
+ /**
372
+ * Get the S3 client instance.
373
+ * Useful for advanced operations not covered by the adapter interface.
374
+ */
375
+ getClient() {
376
+ return this.client;
377
+ }
378
+ /**
379
+ * Get the bucket name.
380
+ */
381
+ getBucket() {
382
+ return this.resolvedConfig.bucket;
383
+ }
384
+ /**
385
+ * Get the AWS region.
386
+ */
387
+ getRegion() {
388
+ return this.resolvedConfig.region;
389
+ }
390
+ /**
391
+ * Check if this adapter is configured for Cloudflare R2.
392
+ */
393
+ isCloudflareR2() {
394
+ return this.isR2;
395
+ }
396
+ };
397
+
398
+ // src/plugin.ts
399
+ function s3Storage(config) {
400
+ if (config.enabled === false) {
401
+ return {
402
+ name: "s3-storage",
403
+ type: "s3",
404
+ collections: {},
405
+ adapter: null
406
+ };
407
+ }
408
+ const adapter = new S3StorageAdapter(config);
409
+ const plugin = {
410
+ name: "s3-storage",
411
+ type: "s3",
412
+ collections: config.collections,
413
+ adapter,
414
+ /**
415
+ * Generate a pre-signed URL for client-side uploads.
416
+ *
417
+ * This allows files to be uploaded directly from the browser to S3,
418
+ * bypassing server-side upload limits (e.g., Vercel's 4.5MB limit).
419
+ *
420
+ * @param filename - Original filename from the client
421
+ * @param mimeType - MIME type of the file
422
+ * @param collection - Collection slug this upload belongs to
423
+ * @returns Client upload data with pre-signed URL
424
+ */
425
+ async getClientUploadUrl(filename, mimeType, collection) {
426
+ const collectionConfig = config.collections[collection];
427
+ const prefix = typeof collectionConfig === "object" ? collectionConfig.prefix : void 0;
428
+ const key = generateStorageKey(filename, prefix);
429
+ return adapter.getPresignedUploadUrl(key, mimeType);
430
+ },
431
+ /**
432
+ * Generate a signed URL for private file downloads.
433
+ *
434
+ * Creates a time-limited URL for accessing files in private buckets.
435
+ * Only works when collection has `signedDownloads: true`.
436
+ *
437
+ * @param path - Storage path/key of the file
438
+ * @param expiresIn - URL validity duration in seconds
439
+ * @returns Signed URL for downloading the file
440
+ */
441
+ async getSignedDownloadUrl(path, expiresIn) {
442
+ return adapter.getSignedUrl(
443
+ path,
444
+ expiresIn ?? config.signedUrlExpiresIn ?? 3600
445
+ );
446
+ }
447
+ };
448
+ return plugin;
449
+ }
450
+ function generateStorageKey(filename, prefix) {
451
+ const sanitized = sanitizeFilename(filename);
452
+ const uuid = crypto.randomUUID();
453
+ const date = /* @__PURE__ */ new Date();
454
+ const year = date.getFullYear();
455
+ const month = String(date.getMonth() + 1).padStart(2, "0");
456
+ const keyPrefix = prefix ? `${prefix}${year}/${month}` : `${year}/${month}`;
457
+ return `${keyPrefix}/${uuid}-${sanitized}`;
458
+ }
459
+ function sanitizeFilename(filename) {
460
+ const basename = filename.split(/[/\\]/).pop() || filename;
461
+ return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
462
+ }
463
+
464
+ // src/index.ts
465
+ var PACKAGE_NAME = "@nextly/storage-s3";
466
+ var PACKAGE_VERSION = "0.1.0";
467
+
468
+ export { PACKAGE_NAME, PACKAGE_VERSION, S3StorageAdapter, s3Storage };
469
+ //# sourceMappingURL=index.mjs.map
470
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/adapter.ts","../src/plugin.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * S3 Storage Adapter\n *\n * Stores files on AWS S3 or S3-compatible services (Cloudflare R2, MinIO, DigitalOcean Spaces).\n * Uses AWS SDK v3 with automatic multipart upload for large files (>5MB).\n *\n * Features:\n * - Automatic multipart upload via @aws-sdk/lib-storage\n * - Pre-signed URL generation for client-side uploads\n * - Signed URL generation for private file access\n * - Cloudflare R2 support (S3-compatible API)\n * - Custom endpoint support (MinIO, DigitalOcean Spaces)\n * - CDN URL override support\n * - File metadata retrieval\n *\n * @example AWS S3\n * ```typescript\n * const adapter = new S3StorageAdapter({\n * bucket: 'my-media-bucket',\n * region: 'us-east-1',\n * accessKeyId: process.env.AWS_ACCESS_KEY_ID!,\n * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,\n * collections: { media: true }\n * });\n * ```\n *\n * @example Cloudflare R2\n * ```typescript\n * const adapter = new S3StorageAdapter({\n * bucket: 'my-media',\n * region: 'auto',\n * accessKeyId: process.env.R2_ACCESS_KEY_ID!,\n * secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,\n * endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n * publicUrl: 'https://pub-abc.r2.dev',\n * collections: { media: true }\n * });\n * ```\n *\n * @example MinIO (self-hosted)\n * ```typescript\n * const adapter = new S3StorageAdapter({\n * bucket: 'my-bucket',\n * region: 'us-east-1',\n * accessKeyId: process.env.MINIO_ACCESS_KEY!,\n * secretAccessKey: process.env.MINIO_SECRET_KEY!,\n * endpoint: 'http://localhost:9000',\n * forcePathStyle: true,\n * collections: { media: true }\n * });\n * ```\n */\n\nimport {\n S3Client,\n DeleteObjectCommand,\n DeleteObjectsCommand,\n HeadObjectCommand,\n GetObjectCommand,\n PutObjectCommand,\n type PutObjectCommandInput,\n} from \"@aws-sdk/client-s3\";\nimport { Upload } from \"@aws-sdk/lib-storage\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport type {\n IStorageAdapter,\n UploadOptions,\n UploadResult,\n StorageAdapterInfo,\n ClientUploadData,\n FileMetadata,\n BulkDeleteResult,\n} from \"nextly/storage\";\n\nimport type { S3StorageConfig, ResolvedS3Config } from \"./types\";\n\n// ============================================================\n// S3 Storage Adapter\n// ============================================================\n\n/**\n * S3 Storage Adapter\n *\n * Implements the IStorageAdapter interface for AWS S3 and S3-compatible services.\n * Provides full support for uploads, downloads, signed URLs, and client-side uploads.\n */\nexport class S3StorageAdapter implements IStorageAdapter {\n private client: S3Client;\n private resolvedConfig: ResolvedS3Config;\n private isR2: boolean;\n\n /**\n * Create a new S3 storage adapter.\n *\n * @param config - S3 storage configuration\n * @throws Error if bucket or region is not provided\n */\n constructor(private config: S3StorageConfig) {\n // Validate required config\n if (!config.bucket) {\n throw new Error(\"@nextly/storage-s3: bucket is required\");\n }\n if (!config.region) {\n throw new Error(\"@nextly/storage-s3: region is required\");\n }\n\n // Detect if this is Cloudflare R2\n this.isR2 = config.endpoint?.includes(\"r2.cloudflarestorage.com\") ?? false;\n\n // Resolve config with defaults\n this.resolvedConfig = {\n bucket: config.bucket,\n region: config.region,\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle ?? false,\n acl: config.acl ?? \"private\",\n publicUrl: config.publicUrl,\n cacheControl: config.cacheControl ?? \"public, max-age=31536000\",\n contentDisposition: config.contentDisposition,\n signedUrlExpiresIn: config.signedUrlExpiresIn ?? 3600,\n };\n\n // Build credentials\n const credentials = this.buildCredentials();\n\n // Initialize S3 client\n this.client = new S3Client({\n region: config.region,\n endpoint: config.endpoint,\n credentials,\n forcePathStyle: this.resolvedConfig.forcePathStyle,\n ...config.config,\n });\n }\n\n /**\n * Build AWS credentials from config.\n * Supports explicit credentials or falls back to SDK default chain.\n */\n private buildCredentials():\n | { accessKeyId: string; secretAccessKey: string }\n | undefined {\n if (this.config.accessKeyId && this.config.secretAccessKey) {\n return {\n accessKeyId: this.config.accessKeyId,\n secretAccessKey: this.config.secretAccessKey,\n };\n }\n\n // Check if credentials are in the config object\n if (this.config.config?.credentials) {\n return undefined; // Let SDK use the provided config.credentials\n }\n\n // Fall back to SDK default credential chain (env vars, IAM roles, etc.)\n return undefined;\n }\n\n // ============================================================\n // Core IStorageAdapter Methods\n // ============================================================\n\n /**\n * Upload file to S3.\n *\n * Uses AWS SDK v3's Upload class from @aws-sdk/lib-storage which:\n * - Automatically handles multipart upload for large files (>5MB)\n * - Provides progress tracking capability\n * - Includes retry logic\n * - Optimizes upload performance\n *\n * @param buffer - File content as Buffer\n * @param options - Upload options (filename, mimeType, folder, collection)\n * @returns Upload result with URL and storage path\n */\n async upload(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {\n const key = this.generateKey(options.filename, options.folder);\n\n // Build upload parameters\n const uploadParams: PutObjectCommandInput = {\n Bucket: this.resolvedConfig.bucket,\n Key: key,\n Body: buffer,\n ContentType: options.contentType || options.mimeType,\n CacheControl: this.resolvedConfig.cacheControl,\n };\n\n // Add ACL if not R2 (R2 ignores ACL settings)\n if (!this.isR2) {\n uploadParams.ACL = this.resolvedConfig.acl;\n }\n\n // Add Content-Disposition: per-file override takes priority, then global config\n const disposition =\n options.contentDisposition ?? this.resolvedConfig.contentDisposition;\n if (disposition) {\n const filename = this.sanitizeFilename(options.filename);\n uploadParams.ContentDisposition =\n disposition === \"attachment\"\n ? `attachment; filename=\"${filename}\"`\n : \"inline\";\n }\n\n // Store original filename in metadata for later retrieval\n uploadParams.Metadata = {\n \"original-filename\": options.filename,\n };\n\n // Use Upload for automatic multipart handling\n const upload = new Upload({\n client: this.client,\n params: uploadParams,\n });\n\n await upload.done();\n\n return {\n url: this.getPublicUrl(key),\n path: key,\n };\n }\n\n /**\n * Delete file from S3.\n *\n * @param filePath - Storage path/key to delete\n */\n async delete(filePath: string): Promise<void> {\n const command = new DeleteObjectCommand({\n Bucket: this.resolvedConfig.bucket,\n Key: filePath,\n });\n\n await this.client.send(command);\n }\n\n /**\n * Bulk delete files from S3 using a single API call per 1000 keys.\n *\n * Uses AWS SDK v3's DeleteObjectsCommand which supports up to 1000 keys per\n * request. Automatically batches larger arrays and collects per-key results.\n *\n * @param filePaths - Storage paths/keys to delete\n * @returns Object with arrays of successful and failed deletions\n */\n async bulkDelete(filePaths: string[]): Promise<BulkDeleteResult> {\n const successful: string[] = [];\n const failed: Array<{ filePath: string; error: string }> = [];\n const maxBatchSize = 1000; // AWS limit\n\n for (let i = 0; i < filePaths.length; i += maxBatchSize) {\n const batch = filePaths.slice(i, i + maxBatchSize);\n const command = new DeleteObjectsCommand({\n Bucket: this.resolvedConfig.bucket,\n Delete: {\n Objects: batch.map(key => ({ Key: key })),\n Quiet: false,\n },\n });\n\n const response = await this.client.send(command);\n\n if (response.Errors && response.Errors.length > 0) {\n for (const err of response.Errors) {\n if (err.Key) {\n failed.push({\n filePath: err.Key,\n error: err.Message ?? \"Unknown S3 delete error\",\n });\n }\n }\n }\n\n if (response.Deleted) {\n for (const del of response.Deleted) {\n if (del.Key) {\n successful.push(del.Key);\n }\n }\n }\n }\n\n return { successful, failed };\n }\n\n /**\n * Check if file exists in S3.\n *\n * Uses HeadObject command which is more efficient than GetObject\n * for existence checks (doesn't download the file).\n *\n * @param filePath - Storage path/key to check\n * @returns true if file exists, false otherwise\n */\n async exists(filePath: string): Promise<boolean> {\n try {\n const command = new HeadObjectCommand({\n Bucket: this.resolvedConfig.bucket,\n Key: filePath,\n });\n\n await this.client.send(command);\n return true;\n } catch (error: unknown) {\n // HeadObject returns 404 if object doesn't exist\n if (this.isNotFoundError(error)) {\n return false;\n }\n // Re-throw other errors (permissions, network, etc.)\n throw error;\n }\n }\n\n /**\n * Get public URL for S3 file.\n *\n * Priority order:\n * 1. Custom publicUrl (CDN or custom domain) if configured\n * 2. Standard S3 URL based on region and bucket\n *\n * For R2: Requires publicUrl configuration (R2 has no default public URLs).\n *\n * @param filePath - Storage path/key\n * @returns Public URL to access the file\n * @throws Error if R2 is used without publicUrl configuration\n */\n getPublicUrl(filePath: string): string {\n // Use custom CDN/public URL if configured\n if (this.resolvedConfig.publicUrl) {\n const baseUrl = this.resolvedConfig.publicUrl.replace(/\\/$/, \"\");\n return `${baseUrl}/${filePath}`;\n }\n\n // R2 requires custom domain or public bucket URL\n if (this.isR2) {\n throw new Error(\n \"@nextly/storage-s3: Cloudflare R2 requires publicUrl configuration.\\n\\n\" +\n \"R2 does not have default public URLs like AWS S3. Configure one of:\\n\" +\n \"1. Public bucket URL: publicUrl: 'https://pub-xxx.r2.dev'\\n\" +\n \"2. Custom domain: publicUrl: 'https://cdn.example.com'\\n\\n\" +\n \"Set up public access in the Cloudflare R2 dashboard.\"\n );\n }\n\n // Standard S3 URL format\n // https://bucket.s3.region.amazonaws.com/key\n return `https://${this.resolvedConfig.bucket}.s3.${this.resolvedConfig.region}.amazonaws.com/${filePath}`;\n }\n\n /**\n * Get storage type identifier.\n *\n * Returns \"s3\" for all S3-compatible services (AWS S3, R2, MinIO, etc.)\n * as they all use the S3 API.\n */\n getType(): \"s3\" {\n return \"s3\";\n }\n\n // ============================================================\n // Optional IStorageAdapter Methods\n // ============================================================\n\n /**\n * Get adapter info including capabilities.\n *\n * @returns Adapter info with type, name, and capability flags\n */\n getInfo(): StorageAdapterInfo {\n return {\n type: \"s3\",\n name: \"S3StorageAdapter\",\n supportsSignedUrls: true,\n supportsClientUploads: true,\n };\n }\n\n /**\n * Get file metadata from S3.\n *\n * Retrieves file information including size, content type, and timestamps\n * using the HeadObject command.\n *\n * @param filePath - Storage path/key\n * @returns File metadata or null if file not found\n */\n async getMetadata(filePath: string): Promise<FileMetadata | null> {\n try {\n const command = new HeadObjectCommand({\n Bucket: this.resolvedConfig.bucket,\n Key: filePath,\n });\n\n const response = await this.client.send(command);\n const filename = filePath.split(\"/\").pop() || filePath;\n\n return {\n id: filePath,\n filename,\n originalFilename: response.Metadata?.[\"original-filename\"] || filename,\n mimeType: response.ContentType || \"application/octet-stream\",\n size: response.ContentLength || 0,\n url: this.getPublicUrl(filePath),\n createdAt:\n response.LastModified?.toISOString() || new Date().toISOString(),\n };\n } catch (error: unknown) {\n if (this.isNotFoundError(error)) {\n return null;\n }\n throw error;\n }\n }\n\n /**\n * Generate signed URL for temporary private file access.\n *\n * Creates a pre-signed GetObject URL that grants temporary read access\n * to private files. Useful for serving files from private buckets.\n *\n * @param filePath - Storage path/key\n * @param expiresIn - URL validity duration in seconds (default: 3600)\n * @returns Pre-signed URL for downloading the file\n */\n async getSignedUrl(filePath: string, expiresIn?: number): Promise<string> {\n const command = new GetObjectCommand({\n Bucket: this.resolvedConfig.bucket,\n Key: filePath,\n });\n\n return getSignedUrl(this.client, command, {\n expiresIn: expiresIn ?? this.resolvedConfig.signedUrlExpiresIn,\n });\n }\n\n /**\n * Generate pre-signed URL for client-side uploads.\n *\n * Creates a pre-signed PutObject URL that allows direct uploads from\n * the browser to S3, bypassing server-side upload limits (e.g., Vercel's 4.5MB).\n *\n * @param key - Storage path/key for the upload\n * @param mimeType - MIME type of the file being uploaded\n * @param expiresIn - URL validity duration in seconds (default: 3600)\n * @returns Client upload data with URL, method, and headers\n */\n async getPresignedUploadUrl(\n key: string,\n mimeType: string,\n expiresIn?: number\n ): Promise<ClientUploadData> {\n const expiration = expiresIn ?? this.resolvedConfig.signedUrlExpiresIn;\n\n // Build PutObject command parameters\n const commandParams: PutObjectCommandInput = {\n Bucket: this.resolvedConfig.bucket,\n Key: key,\n ContentType: mimeType,\n CacheControl: this.resolvedConfig.cacheControl,\n };\n\n // Add ACL if not R2\n if (!this.isR2) {\n commandParams.ACL = this.resolvedConfig.acl;\n }\n\n const command = new PutObjectCommand(commandParams);\n\n const uploadUrl = await getSignedUrl(this.client, command, {\n expiresIn: expiration,\n });\n\n return {\n uploadUrl,\n path: key,\n method: \"PUT\",\n headers: {\n \"Content-Type\": mimeType,\n },\n expiresAt: new Date(Date.now() + expiration * 1000),\n };\n }\n\n // ============================================================\n // Helper Methods\n // ============================================================\n\n /**\n * Generate a unique storage key with date-based prefix.\n *\n * Creates keys in format: {folder}/{year}/{month}/{uuid}-{sanitized-filename}\n *\n * @param filename - Original filename (will be sanitized)\n * @param folder - Optional folder/prefix for organizing uploads\n * @returns Generated storage key\n */\n private generateKey(filename: string, folder?: string): string {\n const sanitized = this.sanitizeFilename(filename);\n const uuid = crypto.randomUUID();\n const date = new Date();\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, \"0\");\n\n const prefix = folder ? `${folder}/${year}/${month}` : `${year}/${month}`;\n\n return `${prefix}/${uuid}-${sanitized}`;\n }\n\n /**\n * Sanitize filename to prevent directory traversal and S3 key issues.\n *\n * @param filename - Original filename\n * @returns Sanitized filename safe for S3 keys\n */\n private sanitizeFilename(filename: string): string {\n const basename = filename.split(/[/\\\\]/).pop() || filename;\n return basename.replace(/[^a-zA-Z0-9._-]/g, \"-\");\n }\n\n /**\n * Check if an error is a \"not found\" error from S3.\n *\n * @param error - Error to check\n * @returns true if error indicates file not found\n */\n private isNotFoundError(error: unknown): boolean {\n if (error && typeof error === \"object\") {\n const e = error as {\n name?: string;\n $metadata?: { httpStatusCode?: number };\n };\n return e.name === \"NotFound\" || e.$metadata?.httpStatusCode === 404;\n }\n return false;\n }\n\n // ============================================================\n // Public Accessors\n // ============================================================\n\n /**\n * Get the S3 client instance.\n * Useful for advanced operations not covered by the adapter interface.\n */\n getClient(): S3Client {\n return this.client;\n }\n\n /**\n * Get the bucket name.\n */\n getBucket(): string {\n return this.resolvedConfig.bucket;\n }\n\n /**\n * Get the AWS region.\n */\n getRegion(): string {\n return this.resolvedConfig.region;\n }\n\n /**\n * Check if this adapter is configured for Cloudflare R2.\n */\n isCloudflareR2(): boolean {\n return this.isR2;\n }\n}\n","/**\n * S3 Storage Plugin\n *\n * Factory function that creates a storage plugin for AWS S3 and S3-compatible services.\n * Returns a StoragePlugin that can be registered with MediaStorage.\n *\n * @example Basic usage with AWS S3\n * ```typescript\n * import { s3Storage } from '@nextly/storage-s3'\n * import { defineConfig } from 'nextly/config'\n *\n * export default defineConfig({\n * storage: [\n * s3Storage({\n * bucket: process.env.S3_BUCKET!,\n * region: process.env.AWS_REGION!,\n * accessKeyId: process.env.AWS_ACCESS_KEY_ID!,\n * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,\n * collections: {\n * media: true\n * }\n * })\n * ]\n * })\n * ```\n *\n * @example With Cloudflare R2\n * ```typescript\n * s3Storage({\n * bucket: process.env.R2_BUCKET!,\n * region: 'auto',\n * accessKeyId: process.env.R2_ACCESS_KEY_ID!,\n * secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,\n * endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,\n * publicUrl: process.env.R2_PUBLIC_URL,\n * collections: { media: true }\n * })\n * ```\n *\n * @example With collection-specific configuration\n * ```typescript\n * s3Storage({\n * bucket: 'my-bucket',\n * region: 'us-east-1',\n * collections: {\n * // Simple enable with defaults\n * media: true,\n *\n * // Full configuration for private documents\n * 'private-docs': {\n * prefix: 'private/',\n * clientUploads: true,\n * signedDownloads: true,\n * signedUrlExpiresIn: 3600\n * }\n * }\n * })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { StoragePlugin, ClientUploadData } from \"nextly/storage\";\n\nimport { S3StorageAdapter } from \"./adapter\";\nimport type { S3StorageConfig } from \"./types\";\n\n// ============================================================\n// Plugin Factory Function\n// ============================================================\n\n/**\n * Create an S3 storage plugin for Nextly.\n *\n * This factory function creates a StoragePlugin that can be added to\n * the `storage` array in `nextly.config.ts`. It supports:\n *\n * - AWS S3 (standard)\n * - Cloudflare R2 (S3-compatible)\n * - MinIO (S3-compatible, self-hosted)\n * - DigitalOcean Spaces (S3-compatible)\n * - Any other S3-compatible service\n *\n * @param config - S3 storage configuration\n * @returns A StoragePlugin that MediaStorage can register\n *\n * @throws Error if bucket is not provided (via adapter)\n * @throws Error if region is not provided (via adapter)\n */\nexport function s3Storage(config: S3StorageConfig): StoragePlugin {\n // Handle disabled plugin\n // When disabled, return a plugin with no collections and null adapter\n // MediaStorage.registerPlugin() checks for null adapter and skips registration\n if (config.enabled === false) {\n return {\n name: \"s3-storage\",\n type: \"s3\",\n collections: {},\n adapter: null as unknown as StoragePlugin[\"adapter\"],\n };\n }\n\n // Create the S3 adapter\n // Adapter constructor validates required config (bucket, region)\n const adapter = new S3StorageAdapter(config);\n\n // Build and return the plugin\n const plugin: StoragePlugin = {\n name: \"s3-storage\",\n type: \"s3\",\n collections: config.collections,\n adapter,\n\n /**\n * Generate a pre-signed URL for client-side uploads.\n *\n * This allows files to be uploaded directly from the browser to S3,\n * bypassing server-side upload limits (e.g., Vercel's 4.5MB limit).\n *\n * @param filename - Original filename from the client\n * @param mimeType - MIME type of the file\n * @param collection - Collection slug this upload belongs to\n * @returns Client upload data with pre-signed URL\n */\n async getClientUploadUrl(\n filename: string,\n mimeType: string,\n collection: string\n ): Promise<ClientUploadData> {\n // Get collection-specific configuration\n const collectionConfig = config.collections[collection];\n const prefix =\n typeof collectionConfig === \"object\"\n ? collectionConfig.prefix\n : undefined;\n\n // Generate storage key matching adapter's format\n // Format: {prefix}{year}/{month}/{uuid}-{sanitized-filename}\n const key = generateStorageKey(filename, prefix);\n\n // Get pre-signed upload URL from adapter\n return adapter.getPresignedUploadUrl(key, mimeType);\n },\n\n /**\n * Generate a signed URL for private file downloads.\n *\n * Creates a time-limited URL for accessing files in private buckets.\n * Only works when collection has `signedDownloads: true`.\n *\n * @param path - Storage path/key of the file\n * @param expiresIn - URL validity duration in seconds\n * @returns Signed URL for downloading the file\n */\n async getSignedDownloadUrl(\n path: string,\n expiresIn?: number\n ): Promise<string> {\n return adapter.getSignedUrl(\n path,\n expiresIn ?? config.signedUrlExpiresIn ?? 3600\n );\n },\n };\n\n return plugin;\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Generate a unique storage key with date-based prefix.\n *\n * Creates keys in format: {prefix}{year}/{month}/{uuid}-{sanitized-filename}\n * This matches the format used by S3StorageAdapter for consistency.\n *\n * @param filename - Original filename (will be sanitized)\n * @param prefix - Optional folder/prefix for organizing uploads\n * @returns Generated storage key\n */\nfunction generateStorageKey(filename: string, prefix?: string): string {\n const sanitized = sanitizeFilename(filename);\n const uuid = crypto.randomUUID();\n const date = new Date();\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, \"0\");\n\n const keyPrefix = prefix ? `${prefix}${year}/${month}` : `${year}/${month}`;\n\n return `${keyPrefix}/${uuid}-${sanitized}`;\n}\n\n/**\n * Sanitize filename to prevent directory traversal and S3 key issues.\n *\n * Removes path separators and replaces unsafe characters with hyphens.\n * Keeps only: a-z, A-Z, 0-9, dot, underscore, hyphen\n *\n * @param filename - Original filename\n * @returns Sanitized filename safe for S3 keys\n */\nfunction sanitizeFilename(filename: string): string {\n // Extract basename (remove any path components)\n const basename = filename.split(/[/\\\\]/).pop() || filename;\n // Replace unsafe characters with hyphens\n return basename.replace(/[^a-zA-Z0-9._-]/g, \"-\");\n}\n","/**\n * @nextly/storage-s3\n *\n * AWS S3 storage adapter for Nextly CMS.\n * Also works with S3-compatible services like Cloudflare R2, MinIO, and DigitalOcean Spaces.\n *\n * @example Basic usage with AWS S3\n * ```typescript\n * import { s3Storage } from '@nextly/storage-s3'\n * import { defineConfig } from 'nextly/config'\n *\n * export default defineConfig({\n * storage: [\n * s3Storage({\n * bucket: process.env.S3_BUCKET!,\n * region: process.env.AWS_REGION!,\n * accessKeyId: process.env.AWS_ACCESS_KEY_ID!,\n * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,\n * collections: {\n * media: true\n * }\n * })\n * ]\n * })\n * ```\n *\n * @example With Cloudflare R2\n * ```typescript\n * s3Storage({\n * bucket: process.env.R2_BUCKET!,\n * region: 'auto',\n * accessKeyId: process.env.R2_ACCESS_KEY_ID!,\n * secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,\n * endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,\n * publicUrl: process.env.R2_PUBLIC_URL,\n * collections: { media: true }\n * })\n * ```\n *\n * @example With MinIO\n * ```typescript\n * s3Storage({\n * bucket: 'my-bucket',\n * region: 'us-east-1',\n * accessKeyId: process.env.MINIO_ACCESS_KEY!,\n * secretAccessKey: process.env.MINIO_SECRET_KEY!,\n * endpoint: 'http://localhost:9000',\n * forcePathStyle: true,\n * collections: { media: true }\n * })\n * ```\n *\n * @packageDocumentation\n */\n\n// ============================================================\n// S3 Storage Plugin Export (Primary API)\n// ============================================================\n\nexport { s3Storage } from \"./plugin\";\n\n// ============================================================\n// S3 Storage Adapter Export\n// ============================================================\n\nexport { S3StorageAdapter } from \"./adapter\";\n\n// ============================================================\n// S3 Types Export\n// ============================================================\n\nexport type {\n S3StorageConfig,\n S3ObjectACL,\n S3CollectionConfig,\n S3CollectionStorageMap,\n ResolvedS3Config,\n} from \"./types\";\n\n// ============================================================\n// Package Metadata\n// ============================================================\n\nexport const PACKAGE_NAME = \"@nextly/storage-s3\";\nexport const PACKAGE_VERSION = \"0.1.0\";\n"]}
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@nextlyhq/storage-s3",
3
+ "version": "0.0.1",
4
+ "description": "AWS S3 storage adapter for Nextly - supports S3, Cloudflare R2, MinIO",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.cjs",
8
+ "module": "dist/index.mjs",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.mjs",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
23
+ "dependencies": {
24
+ "@aws-sdk/client-s3": "^3.966.0",
25
+ "@aws-sdk/lib-storage": "^3.966.0",
26
+ "@aws-sdk/s3-request-presigner": "^3.966.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.0.0",
30
+ "@vitest/coverage-v8": "^4.0.8",
31
+ "@vitest/ui": "^4.0.8",
32
+ "eslint": "^9.34.0",
33
+ "tsup": "^8.5.0",
34
+ "typescript": "^5.9.3",
35
+ "vite-tsconfig-paths": "^5.1.4",
36
+ "vitest": "^4.0.8",
37
+ "@nextlyhq/eslint-config": "0.0.1",
38
+ "@nextlyhq/tsconfig": "0.0.1",
39
+ "nextly": "0.0.1"
40
+ },
41
+ "keywords": [
42
+ "nextly",
43
+ "storage",
44
+ "s3",
45
+ "aws",
46
+ "cloudflare-r2",
47
+ "minio",
48
+ "digitalocean-spaces",
49
+ "media",
50
+ "upload"
51
+ ],
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/nextlyhq/nextly.git",
55
+ "directory": "packages/storage-s3"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "registry": "https://registry.npmjs.org/",
60
+ "provenance": true
61
+ },
62
+ "homepage": "https://nextlyhq.com",
63
+ "bugs": {
64
+ "url": "https://github.com/nextlyhq/nextly/issues"
65
+ },
66
+ "author": "Nextly <contact@nextlyhq.com> (https://nextlyhq.com)",
67
+ "scripts": {
68
+ "build": "tsup",
69
+ "dev": "tsup --watch",
70
+ "check-types": "tsc --noEmit",
71
+ "lint": "eslint . --max-warnings 0",
72
+ "lint:fix": "eslint . --fix",
73
+ "test": "vitest run --passWithNoTests",
74
+ "test:watch": "vitest",
75
+ "test:ui": "vitest --ui",
76
+ "test:coverage": "vitest run --coverage",
77
+ "clean": "rimraf dist"
78
+ }
79
+ }