@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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NextlyHQ <info@nextlyhq.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ 'Software'), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @nextlyhq/storage-s3
2
+
3
+ Amazon S3 (and S3-compatible: Cloudflare R2, MinIO, Backblaze B2, Wasabi, DigitalOcean Spaces) storage adapter for Nextly.
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@nextlyhq/storage-s3"><img alt="npm" src="https://img.shields.io/npm/v/@nextlyhq/storage-s3?style=flat-square&label=npm&color=cb3837" /></a>
7
+ <a href="https://github.com/nextlyhq/nextly/blob/main/LICENSE.md"><img alt="License" src="https://img.shields.io/github/license/nextlyhq/nextly?style=flat-square&color=blue" /></a>
8
+ <a href="https://nextlyhq.com/docs"><img alt="Status" src="https://img.shields.io/badge/status-alpha-orange?style=flat-square" /></a>
9
+ </p>
10
+
11
+ > [!IMPORTANT]
12
+ > Nextly is in alpha. APIs may change before 1.0. Pin exact versions in production.
13
+
14
+ ## What it is
15
+
16
+ Stores Nextly media uploads on Amazon S3 or any S3-compatible object store. R2, MinIO, B2, Wasabi, and DigitalOcean Spaces all work via the same adapter; you do not need separate packages.
17
+
18
+ > **You do not need this for development.** Nextly's default storage is local disk under `./public/uploads/`. Install this when you are ready to move uploads to a cloud object store, typically for production deployments.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pnpm add @nextlyhq/storage-s3
24
+ ```
25
+
26
+ ## Quick usage
27
+
28
+ Register the storage adapter in `nextly.config.ts`:
29
+
30
+ ```ts
31
+ import { defineConfig } from "nextly/config";
32
+ import { s3Storage } from "@nextlyhq/storage-s3";
33
+
34
+ export default defineConfig({
35
+ storage: [
36
+ s3Storage({
37
+ bucket: process.env.S3_BUCKET!,
38
+ region: process.env.AWS_REGION!,
39
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
40
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
41
+ collections: { media: true },
42
+ }),
43
+ ],
44
+ });
45
+ ```
46
+
47
+ ## Required environment variables
48
+
49
+ | Variable | Required? | Default | Notes |
50
+ | ----------------------- | --------------------------- | ------- | ----------------------------- |
51
+ | `S3_BUCKET` | yes | (none) | |
52
+ | `AWS_REGION` | yes for AWS | (none) | Use `auto` for Cloudflare R2. |
53
+ | `AWS_ACCESS_KEY_ID` | yes (if not using IAM role) | (none) | |
54
+ | `AWS_SECRET_ACCESS_KEY` | yes (if not using IAM role) | (none) | |
55
+
56
+ You can also pass these explicitly to `s3Storage(...)` instead of using env vars.
57
+
58
+ ## Cloudflare R2
59
+
60
+ ```ts
61
+ s3Storage({
62
+ bucket: process.env.R2_BUCKET!,
63
+ region: "auto",
64
+ accessKeyId: process.env.R2_ACCESS_KEY_ID!,
65
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
66
+ endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
67
+ publicUrl: process.env.R2_PUBLIC_URL,
68
+ collections: { media: true },
69
+ });
70
+ ```
71
+
72
+ ## MinIO
73
+
74
+ ```ts
75
+ s3Storage({
76
+ bucket: "my-bucket",
77
+ region: "us-east-1",
78
+ endpoint: "https://minio.example.com",
79
+ forcePathStyle: true,
80
+ accessKeyId: process.env.MINIO_ACCESS_KEY!,
81
+ secretAccessKey: process.env.MINIO_SECRET_KEY!,
82
+ collections: { media: true },
83
+ });
84
+ ```
85
+
86
+ ## Per-collection configuration
87
+
88
+ ```ts
89
+ s3Storage({
90
+ bucket: "my-bucket",
91
+ region: "us-east-1",
92
+ collections: {
93
+ media: true,
94
+ "private-docs": {
95
+ prefix: "private/",
96
+ clientUploads: true,
97
+ signedDownloads: true,
98
+ signedUrlExpiresIn: 3600,
99
+ },
100
+ },
101
+ });
102
+ ```
103
+
104
+ ## Main exports
105
+
106
+ - `s3Storage`: plugin factory for `defineConfig.storage`
107
+ - `S3StorageAdapter`: the adapter class (advanced)
108
+ - Type exports: `S3StorageConfig`, `S3CollectionConfig`
109
+
110
+ ## Compatibility
111
+
112
+ | Tool | Version |
113
+ | -------- | ----------------------------------------------------------------------- |
114
+ | Node.js | 20+ |
115
+ | `nextly` | 0.0.x |
116
+ | Stores | AWS S3, Cloudflare R2, MinIO, Backblaze B2, Wasabi, DigitalOcean Spaces |
117
+
118
+ ## Documentation
119
+
120
+ - [**Media and storage docs**](https://nextlyhq.com/docs/guides/media-storage)
121
+
122
+ ## Related packages
123
+
124
+ - [`@nextlyhq/storage-vercel-blob`](../storage-vercel-blob)
125
+ - [`@nextlyhq/storage-uploadthing`](../storage-uploadthing)
126
+
127
+ ## License
128
+
129
+ [MIT](../../LICENSE.md)
package/dist/index.cjs ADDED
@@ -0,0 +1,475 @@
1
+ 'use strict';
2
+
3
+ var clientS3 = require('@aws-sdk/client-s3');
4
+ var libStorage = require('@aws-sdk/lib-storage');
5
+ var s3RequestPresigner = require('@aws-sdk/s3-request-presigner');
6
+
7
+ // src/adapter.ts
8
+ var S3StorageAdapter = class {
9
+ /**
10
+ * Create a new S3 storage adapter.
11
+ *
12
+ * @param config - S3 storage configuration
13
+ * @throws Error if bucket or region is not provided
14
+ */
15
+ constructor(config) {
16
+ this.config = config;
17
+ if (!config.bucket) {
18
+ throw new Error("@nextly/storage-s3: bucket is required");
19
+ }
20
+ if (!config.region) {
21
+ throw new Error("@nextly/storage-s3: region is required");
22
+ }
23
+ this.isR2 = config.endpoint?.includes("r2.cloudflarestorage.com") ?? false;
24
+ this.resolvedConfig = {
25
+ bucket: config.bucket,
26
+ region: config.region,
27
+ endpoint: config.endpoint,
28
+ forcePathStyle: config.forcePathStyle ?? false,
29
+ acl: config.acl ?? "private",
30
+ publicUrl: config.publicUrl,
31
+ cacheControl: config.cacheControl ?? "public, max-age=31536000",
32
+ contentDisposition: config.contentDisposition,
33
+ signedUrlExpiresIn: config.signedUrlExpiresIn ?? 3600
34
+ };
35
+ const credentials = this.buildCredentials();
36
+ this.client = new clientS3.S3Client({
37
+ region: config.region,
38
+ endpoint: config.endpoint,
39
+ credentials,
40
+ forcePathStyle: this.resolvedConfig.forcePathStyle,
41
+ ...config.config
42
+ });
43
+ }
44
+ client;
45
+ resolvedConfig;
46
+ isR2;
47
+ /**
48
+ * Build AWS credentials from config.
49
+ * Supports explicit credentials or falls back to SDK default chain.
50
+ */
51
+ buildCredentials() {
52
+ if (this.config.accessKeyId && this.config.secretAccessKey) {
53
+ return {
54
+ accessKeyId: this.config.accessKeyId,
55
+ secretAccessKey: this.config.secretAccessKey
56
+ };
57
+ }
58
+ if (this.config.config?.credentials) {
59
+ return void 0;
60
+ }
61
+ return void 0;
62
+ }
63
+ // ============================================================
64
+ // Core IStorageAdapter Methods
65
+ // ============================================================
66
+ /**
67
+ * Upload file to S3.
68
+ *
69
+ * Uses AWS SDK v3's Upload class from @aws-sdk/lib-storage which:
70
+ * - Automatically handles multipart upload for large files (>5MB)
71
+ * - Provides progress tracking capability
72
+ * - Includes retry logic
73
+ * - Optimizes upload performance
74
+ *
75
+ * @param buffer - File content as Buffer
76
+ * @param options - Upload options (filename, mimeType, folder, collection)
77
+ * @returns Upload result with URL and storage path
78
+ */
79
+ async upload(buffer, options) {
80
+ const key = this.generateKey(options.filename, options.folder);
81
+ const uploadParams = {
82
+ Bucket: this.resolvedConfig.bucket,
83
+ Key: key,
84
+ Body: buffer,
85
+ ContentType: options.contentType || options.mimeType,
86
+ CacheControl: this.resolvedConfig.cacheControl
87
+ };
88
+ if (!this.isR2) {
89
+ uploadParams.ACL = this.resolvedConfig.acl;
90
+ }
91
+ const disposition = options.contentDisposition ?? this.resolvedConfig.contentDisposition;
92
+ if (disposition) {
93
+ const filename = this.sanitizeFilename(options.filename);
94
+ uploadParams.ContentDisposition = disposition === "attachment" ? `attachment; filename="${filename}"` : "inline";
95
+ }
96
+ uploadParams.Metadata = {
97
+ "original-filename": options.filename
98
+ };
99
+ const upload = new libStorage.Upload({
100
+ client: this.client,
101
+ params: uploadParams
102
+ });
103
+ await upload.done();
104
+ return {
105
+ url: this.getPublicUrl(key),
106
+ path: key
107
+ };
108
+ }
109
+ /**
110
+ * Delete file from S3.
111
+ *
112
+ * @param filePath - Storage path/key to delete
113
+ */
114
+ async delete(filePath) {
115
+ const command = new clientS3.DeleteObjectCommand({
116
+ Bucket: this.resolvedConfig.bucket,
117
+ Key: filePath
118
+ });
119
+ await this.client.send(command);
120
+ }
121
+ /**
122
+ * Bulk delete files from S3 using a single API call per 1000 keys.
123
+ *
124
+ * Uses AWS SDK v3's DeleteObjectsCommand which supports up to 1000 keys per
125
+ * request. Automatically batches larger arrays and collects per-key results.
126
+ *
127
+ * @param filePaths - Storage paths/keys to delete
128
+ * @returns Object with arrays of successful and failed deletions
129
+ */
130
+ async bulkDelete(filePaths) {
131
+ const successful = [];
132
+ const failed = [];
133
+ const maxBatchSize = 1e3;
134
+ for (let i = 0; i < filePaths.length; i += maxBatchSize) {
135
+ const batch = filePaths.slice(i, i + maxBatchSize);
136
+ const command = new clientS3.DeleteObjectsCommand({
137
+ Bucket: this.resolvedConfig.bucket,
138
+ Delete: {
139
+ Objects: batch.map((key) => ({ Key: key })),
140
+ Quiet: false
141
+ }
142
+ });
143
+ const response = await this.client.send(command);
144
+ if (response.Errors && response.Errors.length > 0) {
145
+ for (const err of response.Errors) {
146
+ if (err.Key) {
147
+ failed.push({
148
+ filePath: err.Key,
149
+ error: err.Message ?? "Unknown S3 delete error"
150
+ });
151
+ }
152
+ }
153
+ }
154
+ if (response.Deleted) {
155
+ for (const del of response.Deleted) {
156
+ if (del.Key) {
157
+ successful.push(del.Key);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ return { successful, failed };
163
+ }
164
+ /**
165
+ * Check if file exists in S3.
166
+ *
167
+ * Uses HeadObject command which is more efficient than GetObject
168
+ * for existence checks (doesn't download the file).
169
+ *
170
+ * @param filePath - Storage path/key to check
171
+ * @returns true if file exists, false otherwise
172
+ */
173
+ async exists(filePath) {
174
+ try {
175
+ const command = new clientS3.HeadObjectCommand({
176
+ Bucket: this.resolvedConfig.bucket,
177
+ Key: filePath
178
+ });
179
+ await this.client.send(command);
180
+ return true;
181
+ } catch (error) {
182
+ if (this.isNotFoundError(error)) {
183
+ return false;
184
+ }
185
+ throw error;
186
+ }
187
+ }
188
+ /**
189
+ * Get public URL for S3 file.
190
+ *
191
+ * Priority order:
192
+ * 1. Custom publicUrl (CDN or custom domain) if configured
193
+ * 2. Standard S3 URL based on region and bucket
194
+ *
195
+ * For R2: Requires publicUrl configuration (R2 has no default public URLs).
196
+ *
197
+ * @param filePath - Storage path/key
198
+ * @returns Public URL to access the file
199
+ * @throws Error if R2 is used without publicUrl configuration
200
+ */
201
+ getPublicUrl(filePath) {
202
+ if (this.resolvedConfig.publicUrl) {
203
+ const baseUrl = this.resolvedConfig.publicUrl.replace(/\/$/, "");
204
+ return `${baseUrl}/${filePath}`;
205
+ }
206
+ if (this.isR2) {
207
+ throw new Error(
208
+ "@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."
209
+ );
210
+ }
211
+ return `https://${this.resolvedConfig.bucket}.s3.${this.resolvedConfig.region}.amazonaws.com/${filePath}`;
212
+ }
213
+ /**
214
+ * Get storage type identifier.
215
+ *
216
+ * Returns "s3" for all S3-compatible services (AWS S3, R2, MinIO, etc.)
217
+ * as they all use the S3 API.
218
+ */
219
+ getType() {
220
+ return "s3";
221
+ }
222
+ // ============================================================
223
+ // Optional IStorageAdapter Methods
224
+ // ============================================================
225
+ /**
226
+ * Get adapter info including capabilities.
227
+ *
228
+ * @returns Adapter info with type, name, and capability flags
229
+ */
230
+ getInfo() {
231
+ return {
232
+ type: "s3",
233
+ name: "S3StorageAdapter",
234
+ supportsSignedUrls: true,
235
+ supportsClientUploads: true
236
+ };
237
+ }
238
+ /**
239
+ * Get file metadata from S3.
240
+ *
241
+ * Retrieves file information including size, content type, and timestamps
242
+ * using the HeadObject command.
243
+ *
244
+ * @param filePath - Storage path/key
245
+ * @returns File metadata or null if file not found
246
+ */
247
+ async getMetadata(filePath) {
248
+ try {
249
+ const command = new clientS3.HeadObjectCommand({
250
+ Bucket: this.resolvedConfig.bucket,
251
+ Key: filePath
252
+ });
253
+ const response = await this.client.send(command);
254
+ const filename = filePath.split("/").pop() || filePath;
255
+ return {
256
+ id: filePath,
257
+ filename,
258
+ originalFilename: response.Metadata?.["original-filename"] || filename,
259
+ mimeType: response.ContentType || "application/octet-stream",
260
+ size: response.ContentLength || 0,
261
+ url: this.getPublicUrl(filePath),
262
+ createdAt: response.LastModified?.toISOString() || (/* @__PURE__ */ new Date()).toISOString()
263
+ };
264
+ } catch (error) {
265
+ if (this.isNotFoundError(error)) {
266
+ return null;
267
+ }
268
+ throw error;
269
+ }
270
+ }
271
+ /**
272
+ * Generate signed URL for temporary private file access.
273
+ *
274
+ * Creates a pre-signed GetObject URL that grants temporary read access
275
+ * to private files. Useful for serving files from private buckets.
276
+ *
277
+ * @param filePath - Storage path/key
278
+ * @param expiresIn - URL validity duration in seconds (default: 3600)
279
+ * @returns Pre-signed URL for downloading the file
280
+ */
281
+ async getSignedUrl(filePath, expiresIn) {
282
+ const command = new clientS3.GetObjectCommand({
283
+ Bucket: this.resolvedConfig.bucket,
284
+ Key: filePath
285
+ });
286
+ return s3RequestPresigner.getSignedUrl(this.client, command, {
287
+ expiresIn: expiresIn ?? this.resolvedConfig.signedUrlExpiresIn
288
+ });
289
+ }
290
+ /**
291
+ * Generate pre-signed URL for client-side uploads.
292
+ *
293
+ * Creates a pre-signed PutObject URL that allows direct uploads from
294
+ * the browser to S3, bypassing server-side upload limits (e.g., Vercel's 4.5MB).
295
+ *
296
+ * @param key - Storage path/key for the upload
297
+ * @param mimeType - MIME type of the file being uploaded
298
+ * @param expiresIn - URL validity duration in seconds (default: 3600)
299
+ * @returns Client upload data with URL, method, and headers
300
+ */
301
+ async getPresignedUploadUrl(key, mimeType, expiresIn) {
302
+ const expiration = expiresIn ?? this.resolvedConfig.signedUrlExpiresIn;
303
+ const commandParams = {
304
+ Bucket: this.resolvedConfig.bucket,
305
+ Key: key,
306
+ ContentType: mimeType,
307
+ CacheControl: this.resolvedConfig.cacheControl
308
+ };
309
+ if (!this.isR2) {
310
+ commandParams.ACL = this.resolvedConfig.acl;
311
+ }
312
+ const command = new clientS3.PutObjectCommand(commandParams);
313
+ const uploadUrl = await s3RequestPresigner.getSignedUrl(this.client, command, {
314
+ expiresIn: expiration
315
+ });
316
+ return {
317
+ uploadUrl,
318
+ path: key,
319
+ method: "PUT",
320
+ headers: {
321
+ "Content-Type": mimeType
322
+ },
323
+ expiresAt: new Date(Date.now() + expiration * 1e3)
324
+ };
325
+ }
326
+ // ============================================================
327
+ // Helper Methods
328
+ // ============================================================
329
+ /**
330
+ * Generate a unique storage key with date-based prefix.
331
+ *
332
+ * Creates keys in format: {folder}/{year}/{month}/{uuid}-{sanitized-filename}
333
+ *
334
+ * @param filename - Original filename (will be sanitized)
335
+ * @param folder - Optional folder/prefix for organizing uploads
336
+ * @returns Generated storage key
337
+ */
338
+ generateKey(filename, folder) {
339
+ const sanitized = this.sanitizeFilename(filename);
340
+ const uuid = crypto.randomUUID();
341
+ const date = /* @__PURE__ */ new Date();
342
+ const year = date.getFullYear();
343
+ const month = String(date.getMonth() + 1).padStart(2, "0");
344
+ const prefix = folder ? `${folder}/${year}/${month}` : `${year}/${month}`;
345
+ return `${prefix}/${uuid}-${sanitized}`;
346
+ }
347
+ /**
348
+ * Sanitize filename to prevent directory traversal and S3 key issues.
349
+ *
350
+ * @param filename - Original filename
351
+ * @returns Sanitized filename safe for S3 keys
352
+ */
353
+ sanitizeFilename(filename) {
354
+ const basename = filename.split(/[/\\]/).pop() || filename;
355
+ return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
356
+ }
357
+ /**
358
+ * Check if an error is a "not found" error from S3.
359
+ *
360
+ * @param error - Error to check
361
+ * @returns true if error indicates file not found
362
+ */
363
+ isNotFoundError(error) {
364
+ if (error && typeof error === "object") {
365
+ const e = error;
366
+ return e.name === "NotFound" || e.$metadata?.httpStatusCode === 404;
367
+ }
368
+ return false;
369
+ }
370
+ // ============================================================
371
+ // Public Accessors
372
+ // ============================================================
373
+ /**
374
+ * Get the S3 client instance.
375
+ * Useful for advanced operations not covered by the adapter interface.
376
+ */
377
+ getClient() {
378
+ return this.client;
379
+ }
380
+ /**
381
+ * Get the bucket name.
382
+ */
383
+ getBucket() {
384
+ return this.resolvedConfig.bucket;
385
+ }
386
+ /**
387
+ * Get the AWS region.
388
+ */
389
+ getRegion() {
390
+ return this.resolvedConfig.region;
391
+ }
392
+ /**
393
+ * Check if this adapter is configured for Cloudflare R2.
394
+ */
395
+ isCloudflareR2() {
396
+ return this.isR2;
397
+ }
398
+ };
399
+
400
+ // src/plugin.ts
401
+ function s3Storage(config) {
402
+ if (config.enabled === false) {
403
+ return {
404
+ name: "s3-storage",
405
+ type: "s3",
406
+ collections: {},
407
+ adapter: null
408
+ };
409
+ }
410
+ const adapter = new S3StorageAdapter(config);
411
+ const plugin = {
412
+ name: "s3-storage",
413
+ type: "s3",
414
+ collections: config.collections,
415
+ adapter,
416
+ /**
417
+ * Generate a pre-signed URL for client-side uploads.
418
+ *
419
+ * This allows files to be uploaded directly from the browser to S3,
420
+ * bypassing server-side upload limits (e.g., Vercel's 4.5MB limit).
421
+ *
422
+ * @param filename - Original filename from the client
423
+ * @param mimeType - MIME type of the file
424
+ * @param collection - Collection slug this upload belongs to
425
+ * @returns Client upload data with pre-signed URL
426
+ */
427
+ async getClientUploadUrl(filename, mimeType, collection) {
428
+ const collectionConfig = config.collections[collection];
429
+ const prefix = typeof collectionConfig === "object" ? collectionConfig.prefix : void 0;
430
+ const key = generateStorageKey(filename, prefix);
431
+ return adapter.getPresignedUploadUrl(key, mimeType);
432
+ },
433
+ /**
434
+ * Generate a signed URL for private file downloads.
435
+ *
436
+ * Creates a time-limited URL for accessing files in private buckets.
437
+ * Only works when collection has `signedDownloads: true`.
438
+ *
439
+ * @param path - Storage path/key of the file
440
+ * @param expiresIn - URL validity duration in seconds
441
+ * @returns Signed URL for downloading the file
442
+ */
443
+ async getSignedDownloadUrl(path, expiresIn) {
444
+ return adapter.getSignedUrl(
445
+ path,
446
+ expiresIn ?? config.signedUrlExpiresIn ?? 3600
447
+ );
448
+ }
449
+ };
450
+ return plugin;
451
+ }
452
+ function generateStorageKey(filename, prefix) {
453
+ const sanitized = sanitizeFilename(filename);
454
+ const uuid = crypto.randomUUID();
455
+ const date = /* @__PURE__ */ new Date();
456
+ const year = date.getFullYear();
457
+ const month = String(date.getMonth() + 1).padStart(2, "0");
458
+ const keyPrefix = prefix ? `${prefix}${year}/${month}` : `${year}/${month}`;
459
+ return `${keyPrefix}/${uuid}-${sanitized}`;
460
+ }
461
+ function sanitizeFilename(filename) {
462
+ const basename = filename.split(/[/\\]/).pop() || filename;
463
+ return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
464
+ }
465
+
466
+ // src/index.ts
467
+ var PACKAGE_NAME = "@nextly/storage-s3";
468
+ var PACKAGE_VERSION = "0.1.0";
469
+
470
+ exports.PACKAGE_NAME = PACKAGE_NAME;
471
+ exports.PACKAGE_VERSION = PACKAGE_VERSION;
472
+ exports.S3StorageAdapter = S3StorageAdapter;
473
+ exports.s3Storage = s3Storage;
474
+ //# sourceMappingURL=index.cjs.map
475
+ //# sourceMappingURL=index.cjs.map