@nextlyhq/storage-vercel-blob 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 +22 -0
- package/README.md +90 -0
- package/dist/index.cjs +399 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +727 -0
- package/dist/index.d.ts +727 -0
- package/dist/index.mjs +394 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +76 -0
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,90 @@
|
|
|
1
|
+
# @nextlyhq/storage-vercel-blob
|
|
2
|
+
|
|
3
|
+
[Vercel Blob](https://vercel.com/docs/storage/vercel-blob) storage adapter for Nextly. Optimized for Vercel deployments with built-in support for client-side uploads to bypass the 4.5 MB serverless body limit.
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@nextlyhq/storage-vercel-blob"><img alt="npm" src="https://img.shields.io/npm/v/@nextlyhq/storage-vercel-blob?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 Vercel Blob. The simplest option if you are deploying to [Vercel](https://vercel.com?utm_source=nextly&utm_medium=readme) and want zero infrastructure setup. Client-side uploads (large files bypass the serverless body limit) are enabled with one config flag.
|
|
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 Vercel Blob, typically for production deployments to Vercel.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add @nextlyhq/storage-vercel-blob
|
|
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 { vercelBlobStorage } from "@nextlyhq/storage-vercel-blob";
|
|
33
|
+
|
|
34
|
+
export default defineConfig({
|
|
35
|
+
storage: [
|
|
36
|
+
vercelBlobStorage({
|
|
37
|
+
token: process.env.BLOB_READ_WRITE_TOKEN!,
|
|
38
|
+
collections: { media: true },
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Required environment variables
|
|
45
|
+
|
|
46
|
+
| Variable | Required? | Default | Notes |
|
|
47
|
+
| ----------------------- | --------- | ------- | ---------------------------------------------------------------------- |
|
|
48
|
+
| `BLOB_READ_WRITE_TOKEN` | yes | (none) | Provisioned automatically by Vercel when you enable Blob in a project. |
|
|
49
|
+
|
|
50
|
+
## Client-side uploads
|
|
51
|
+
|
|
52
|
+
For files larger than ~4.5 MB on Vercel's serverless functions, enable client uploads:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
vercelBlobStorage({
|
|
56
|
+
token: process.env.BLOB_READ_WRITE_TOKEN!,
|
|
57
|
+
collections: {
|
|
58
|
+
media: { clientUploads: true },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The admin uploader hands the file to the browser-side SDK, which uploads directly to Blob. The server only signs the upload.
|
|
64
|
+
|
|
65
|
+
## Main exports
|
|
66
|
+
|
|
67
|
+
- `vercelBlobStorage`: plugin factory for `defineConfig.storage`
|
|
68
|
+
- `VercelBlobStorageAdapter`: the adapter class (advanced)
|
|
69
|
+
- Type exports: `VercelBlobStorageConfig`
|
|
70
|
+
|
|
71
|
+
## Compatibility
|
|
72
|
+
|
|
73
|
+
| Tool | Version |
|
|
74
|
+
| -------------- | ------- |
|
|
75
|
+
| Node.js | 20+ |
|
|
76
|
+
| `@vercel/blob` | peer |
|
|
77
|
+
| `nextly` | 0.0.x |
|
|
78
|
+
|
|
79
|
+
## Documentation
|
|
80
|
+
|
|
81
|
+
- [**Media and storage docs**](https://nextlyhq.com/docs/guides/media-storage)
|
|
82
|
+
|
|
83
|
+
## Related packages
|
|
84
|
+
|
|
85
|
+
- [`@nextlyhq/storage-s3`](../storage-s3)
|
|
86
|
+
- [`@nextlyhq/storage-uploadthing`](../storage-uploadthing)
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
[MIT](../../LICENSE.md)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var blob = require('@vercel/blob');
|
|
4
|
+
|
|
5
|
+
// src/adapter.ts
|
|
6
|
+
var VercelBlobStorageAdapter = class {
|
|
7
|
+
/**
|
|
8
|
+
* Create a new Vercel Blob storage adapter.
|
|
9
|
+
*
|
|
10
|
+
* @param config - Vercel Blob storage configuration
|
|
11
|
+
* @throws Error if token is not provided
|
|
12
|
+
*/
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
const token = config.token || process.env.BLOB_READ_WRITE_TOKEN || "";
|
|
16
|
+
if (!token) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
"@nextly/storage-vercel-blob: token is required.\n\nEither set the BLOB_READ_WRITE_TOKEN environment variable or pass token in config.\nGet your token from: Vercel Dashboard > Storage > Blob > Tokens"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
this.resolvedConfig = {
|
|
22
|
+
token,
|
|
23
|
+
addRandomSuffix: config.addRandomSuffix ?? true,
|
|
24
|
+
cacheControlMaxAge: config.cacheControlMaxAge ?? 31536e3,
|
|
25
|
+
access: config.access ?? "public",
|
|
26
|
+
storeId: config.storeId,
|
|
27
|
+
allowOverwrite: config.allowOverwrite ?? false,
|
|
28
|
+
multipartThreshold: config.multipartThreshold ?? 5 * 1024 * 1024
|
|
29
|
+
// 5MB
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
resolvedConfig;
|
|
33
|
+
// ============================================================
|
|
34
|
+
// Core IStorageAdapter Methods
|
|
35
|
+
// ============================================================
|
|
36
|
+
/**
|
|
37
|
+
* Upload file to Vercel Blob.
|
|
38
|
+
*
|
|
39
|
+
* Files are stored with globally distributed CDN backing.
|
|
40
|
+
* By default, a random suffix is added to prevent filename collisions.
|
|
41
|
+
*
|
|
42
|
+
* @param buffer - File content as Buffer
|
|
43
|
+
* @param options - Upload options (filename, mimeType, folder, collection)
|
|
44
|
+
* @returns Upload result with URL and storage path
|
|
45
|
+
*/
|
|
46
|
+
async upload(buffer, options) {
|
|
47
|
+
const mimeType = (options.contentType || options.mimeType || "").toLowerCase().trim();
|
|
48
|
+
const filename = (options.filename || "").toLowerCase();
|
|
49
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : "";
|
|
50
|
+
const isSvg = mimeType === "image/svg+xml" || ext === "svg" || ext === "svgz";
|
|
51
|
+
const isHtml = mimeType === "text/html" || mimeType === "application/xhtml+xml" || ext === "html" || ext === "htm" || ext === "xhtml";
|
|
52
|
+
if (isSvg || isHtml) {
|
|
53
|
+
const kind = isSvg ? "SVG" : "HTML";
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[nextly/storage-vercel-blob] ${kind} uploads are rejected on Vercel Blob \u2014 the platform cannot serve them with attachment-disposition or a restrictive CSP, so they would be stored XSS. Use the S3 / R2 adapter for ${kind} files, or convert to a raster format (PNG/WebP).`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const pathname = this.buildPathname(options.filename, options.folder);
|
|
59
|
+
const result = await blob.put(pathname, buffer, {
|
|
60
|
+
access: this.resolvedConfig.access,
|
|
61
|
+
token: this.resolvedConfig.token,
|
|
62
|
+
contentType: options.contentType || options.mimeType,
|
|
63
|
+
addRandomSuffix: this.resolvedConfig.addRandomSuffix,
|
|
64
|
+
cacheControlMaxAge: this.resolvedConfig.cacheControlMaxAge
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
url: result.url,
|
|
68
|
+
path: result.url
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Delete file from Vercel Blob.
|
|
73
|
+
*
|
|
74
|
+
* @param filePath - Full blob URL to delete
|
|
75
|
+
*/
|
|
76
|
+
async delete(filePath) {
|
|
77
|
+
await blob.del(filePath, {
|
|
78
|
+
token: this.resolvedConfig.token
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Delete multiple files from Vercel Blob in chunked batches.
|
|
83
|
+
*
|
|
84
|
+
* Processes deletions in chunks of 10 concurrent calls using Promise.allSettled,
|
|
85
|
+
* collecting successes and failures without short-circuiting.
|
|
86
|
+
*
|
|
87
|
+
* @param filePaths - Array of full blob URLs to delete
|
|
88
|
+
* @returns BulkDeleteResult with successful and failed arrays
|
|
89
|
+
*/
|
|
90
|
+
async bulkDelete(filePaths) {
|
|
91
|
+
const successful = [];
|
|
92
|
+
const failed = [];
|
|
93
|
+
const chunkSize = 10;
|
|
94
|
+
for (let i = 0; i < filePaths.length; i += chunkSize) {
|
|
95
|
+
const chunk = filePaths.slice(i, i + chunkSize);
|
|
96
|
+
const results = await Promise.allSettled(
|
|
97
|
+
chunk.map((fp) => blob.del(fp, { token: this.resolvedConfig.token }))
|
|
98
|
+
);
|
|
99
|
+
results.forEach((result, idx) => {
|
|
100
|
+
const fp = chunk[idx];
|
|
101
|
+
if (result.status === "fulfilled") {
|
|
102
|
+
successful.push(fp);
|
|
103
|
+
} else {
|
|
104
|
+
failed.push({
|
|
105
|
+
filePath: fp,
|
|
106
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return { successful, failed };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Check if file exists in Vercel Blob.
|
|
115
|
+
*
|
|
116
|
+
* Uses the head() function which is efficient for existence checks.
|
|
117
|
+
*
|
|
118
|
+
* @param filePath - Full blob URL to check
|
|
119
|
+
* @returns true if file exists, false otherwise
|
|
120
|
+
*/
|
|
121
|
+
async exists(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
await blob.head(filePath, {
|
|
124
|
+
token: this.resolvedConfig.token
|
|
125
|
+
});
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get public URL for Vercel Blob file.
|
|
133
|
+
*
|
|
134
|
+
* Vercel Blob paths ARE the public URLs - they're already full HTTPS URLs
|
|
135
|
+
* with CDN distribution.
|
|
136
|
+
*
|
|
137
|
+
* @param filePath - Full blob URL
|
|
138
|
+
* @returns The same URL (Vercel Blob paths are already public URLs)
|
|
139
|
+
*/
|
|
140
|
+
getPublicUrl(filePath) {
|
|
141
|
+
return filePath;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get storage type identifier.
|
|
145
|
+
*/
|
|
146
|
+
getType() {
|
|
147
|
+
return "vercel-blob";
|
|
148
|
+
}
|
|
149
|
+
// ============================================================
|
|
150
|
+
// Optional IStorageAdapter Methods
|
|
151
|
+
// ============================================================
|
|
152
|
+
/**
|
|
153
|
+
* Get adapter info including capabilities.
|
|
154
|
+
*
|
|
155
|
+
* @returns Adapter info with type, name, and capability flags
|
|
156
|
+
*/
|
|
157
|
+
getInfo() {
|
|
158
|
+
return {
|
|
159
|
+
type: "vercel-blob",
|
|
160
|
+
name: "VercelBlobStorageAdapter",
|
|
161
|
+
supportsSignedUrls: false,
|
|
162
|
+
// Vercel Blob URLs are public by default
|
|
163
|
+
supportsClientUploads: true
|
|
164
|
+
// Via handleUpload API
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Get file metadata from Vercel Blob.
|
|
169
|
+
*
|
|
170
|
+
* Retrieves file information including size, content type, and upload date.
|
|
171
|
+
*
|
|
172
|
+
* @param filePath - Full blob URL
|
|
173
|
+
* @returns File metadata or null if file not found
|
|
174
|
+
*/
|
|
175
|
+
async getMetadata(filePath) {
|
|
176
|
+
try {
|
|
177
|
+
const result = await blob.head(filePath, {
|
|
178
|
+
token: this.resolvedConfig.token
|
|
179
|
+
});
|
|
180
|
+
const filename = result.pathname.split("/").pop() || result.pathname;
|
|
181
|
+
return {
|
|
182
|
+
id: filePath,
|
|
183
|
+
filename,
|
|
184
|
+
originalFilename: filename,
|
|
185
|
+
mimeType: result.contentType,
|
|
186
|
+
size: result.size,
|
|
187
|
+
url: result.url,
|
|
188
|
+
createdAt: result.uploadedAt.toISOString()
|
|
189
|
+
};
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Generate pre-signed URL for client-side uploads.
|
|
196
|
+
*
|
|
197
|
+
* Vercel Blob uses a different pattern for client uploads - they require
|
|
198
|
+
* the handleUpload API which handles token generation server-side.
|
|
199
|
+
*
|
|
200
|
+
* This method is not implemented because Vercel Blob's client upload flow
|
|
201
|
+
* requires server-side route handlers with handleUpload().
|
|
202
|
+
*
|
|
203
|
+
* @see https://vercel.com/docs/storage/vercel-blob/client-upload
|
|
204
|
+
* @throws Error always - client uploads require handleUpload API
|
|
205
|
+
*/
|
|
206
|
+
getPresignedUploadUrl(_key, _mimeType, _expiresIn) {
|
|
207
|
+
return Promise.reject(
|
|
208
|
+
new Error(
|
|
209
|
+
"@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern for client-side uploads that involves:\n1. A server-side route handler with handleUpload()\n2. The @vercel/blob/client upload() function\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload\n\nFor server-side uploads, use the standard upload() method instead."
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
// ============================================================
|
|
214
|
+
// Additional Methods
|
|
215
|
+
// ============================================================
|
|
216
|
+
/**
|
|
217
|
+
* List blobs with optional prefix filter.
|
|
218
|
+
*
|
|
219
|
+
* Useful for browsing or batch operations on stored files.
|
|
220
|
+
*
|
|
221
|
+
* @param prefix - Optional prefix to filter results
|
|
222
|
+
* @param options - List options (limit, cursor)
|
|
223
|
+
* @returns List of blob metadata
|
|
224
|
+
*/
|
|
225
|
+
async listBlobs(prefix, options) {
|
|
226
|
+
const result = await blob.list({
|
|
227
|
+
token: this.resolvedConfig.token,
|
|
228
|
+
prefix,
|
|
229
|
+
limit: options?.limit,
|
|
230
|
+
cursor: options?.cursor
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
blobs: result.blobs.map((blob) => ({
|
|
234
|
+
url: blob.url,
|
|
235
|
+
pathname: blob.pathname,
|
|
236
|
+
size: blob.size,
|
|
237
|
+
uploadedAt: blob.uploadedAt
|
|
238
|
+
})),
|
|
239
|
+
hasMore: result.hasMore,
|
|
240
|
+
cursor: result.cursor
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// ============================================================
|
|
244
|
+
// Helper Methods
|
|
245
|
+
// ============================================================
|
|
246
|
+
/**
|
|
247
|
+
* Build pathname for Vercel Blob upload.
|
|
248
|
+
*
|
|
249
|
+
* Creates pathname in format: {folder}/{filename}
|
|
250
|
+
* If no folder is provided, uses the filename directly.
|
|
251
|
+
*
|
|
252
|
+
* @param filename - Original filename (will be sanitized)
|
|
253
|
+
* @param folder - Optional folder/prefix for organizing uploads
|
|
254
|
+
* @returns Generated pathname
|
|
255
|
+
* @throws Error if folder fails sanitization
|
|
256
|
+
*/
|
|
257
|
+
buildPathname(filename, folder) {
|
|
258
|
+
const sanitized = this.sanitizeFilename(filename);
|
|
259
|
+
if (!folder) return sanitized;
|
|
260
|
+
return `${this.sanitizeFolder(folder)}/${sanitized}`;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Sanitize filename to prevent path traversal issues.
|
|
264
|
+
*
|
|
265
|
+
* @param filename - Original filename
|
|
266
|
+
* @returns Sanitized filename
|
|
267
|
+
*/
|
|
268
|
+
sanitizeFilename(filename) {
|
|
269
|
+
const basename = filename.split(/[/\\]/).pop() || filename;
|
|
270
|
+
return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Reject folder values that would let a caller
|
|
274
|
+
* escape the configured prefix via path traversal or weird
|
|
275
|
+
* separators. Vercel Blob is flat-keyed so `/` is just part of the
|
|
276
|
+
* pathname, but an attacker who controls `folder` could still:
|
|
277
|
+
*
|
|
278
|
+
* - Use `..` to chain a relative-traversal-style key.
|
|
279
|
+
* - Use `\` (Windows separator) to confuse downstream tooling.
|
|
280
|
+
* - Use control chars / null bytes to truncate the key.
|
|
281
|
+
* - Use a leading `/` to look like an absolute path.
|
|
282
|
+
*
|
|
283
|
+
* Internal `/` segments stay allowed because callers legitimately
|
|
284
|
+
* use nested folders (`docs/2026/...`); a leading `/`, empty
|
|
285
|
+
* segments (`docs//x`), or `..` segments are rejected.
|
|
286
|
+
*
|
|
287
|
+
* @throws Error with a stable message so the storage facade can
|
|
288
|
+
* surface a 400 instead of a 500.
|
|
289
|
+
*/
|
|
290
|
+
sanitizeFolder(folder) {
|
|
291
|
+
const trimmed = folder.replace(/\/+$/, "");
|
|
292
|
+
if (!trimmed) {
|
|
293
|
+
throw new Error("Folder must be a non-empty string.");
|
|
294
|
+
}
|
|
295
|
+
if (/[\\\0\x01-\x1f\x7f]/.test(trimmed)) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
"Folder contains forbidden characters (backslash, control chars, or null bytes)."
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (trimmed.startsWith("/")) {
|
|
301
|
+
throw new Error("Folder must not start with `/`.");
|
|
302
|
+
}
|
|
303
|
+
const segments = trimmed.split("/");
|
|
304
|
+
for (const segment of segments) {
|
|
305
|
+
if (segment === "") {
|
|
306
|
+
throw new Error("Folder must not contain empty segments (`//`).");
|
|
307
|
+
}
|
|
308
|
+
if (segment === "..") {
|
|
309
|
+
throw new Error("Folder must not contain `..` segments.");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return trimmed;
|
|
313
|
+
}
|
|
314
|
+
// ============================================================
|
|
315
|
+
// Public Accessors
|
|
316
|
+
// ============================================================
|
|
317
|
+
/**
|
|
318
|
+
* Get the configured token (masked for logging).
|
|
319
|
+
*/
|
|
320
|
+
getTokenMasked() {
|
|
321
|
+
const token = this.resolvedConfig.token;
|
|
322
|
+
if (token.length <= 8) return "****";
|
|
323
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Check if random suffix is enabled.
|
|
327
|
+
*/
|
|
328
|
+
hasRandomSuffix() {
|
|
329
|
+
return this.resolvedConfig.addRandomSuffix;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Get configured cache control max age.
|
|
333
|
+
*/
|
|
334
|
+
getCacheControlMaxAge() {
|
|
335
|
+
return this.resolvedConfig.cacheControlMaxAge;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// src/plugin.ts
|
|
340
|
+
function vercelBlobStorage(config) {
|
|
341
|
+
if (config.enabled === false) {
|
|
342
|
+
return {
|
|
343
|
+
name: "vercel-blob-storage",
|
|
344
|
+
type: "vercel-blob",
|
|
345
|
+
collections: {},
|
|
346
|
+
adapter: null
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const adapter = new VercelBlobStorageAdapter(config);
|
|
350
|
+
const plugin = {
|
|
351
|
+
name: "vercel-blob-storage",
|
|
352
|
+
type: "vercel-blob",
|
|
353
|
+
collections: config.collections,
|
|
354
|
+
adapter,
|
|
355
|
+
/**
|
|
356
|
+
* Generate upload URL for client-side uploads.
|
|
357
|
+
*
|
|
358
|
+
* Note: Vercel Blob uses a different pattern for client uploads.
|
|
359
|
+
* Instead of pre-signed URLs, it uses the handleUpload() API which
|
|
360
|
+
* requires server-side route handlers.
|
|
361
|
+
*
|
|
362
|
+
* This method throws an error explaining the correct approach.
|
|
363
|
+
*
|
|
364
|
+
* @see https://vercel.com/docs/storage/vercel-blob/client-upload
|
|
365
|
+
*/
|
|
366
|
+
getClientUploadUrl(_filename, _mimeType, _collection) {
|
|
367
|
+
return Promise.reject(
|
|
368
|
+
new Error(
|
|
369
|
+
"@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern than S3 for client-side uploads:\n\n1. Create a server-side route handler with handleUpload()\n2. Use upload() from @vercel/blob/client on the frontend\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload"
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
},
|
|
373
|
+
/**
|
|
374
|
+
* Get signed download URL for file access.
|
|
375
|
+
*
|
|
376
|
+
* Note: Vercel Blob URLs are public by default and do not support
|
|
377
|
+
* signed/temporary URLs. All uploaded blobs are accessible via their
|
|
378
|
+
* public URL.
|
|
379
|
+
*
|
|
380
|
+
* @param path - Storage path (full blob URL)
|
|
381
|
+
* @returns The same URL (Vercel Blob URLs are public)
|
|
382
|
+
*/
|
|
383
|
+
getSignedDownloadUrl(path) {
|
|
384
|
+
return Promise.resolve(path);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
return plugin;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/index.ts
|
|
391
|
+
var PACKAGE_NAME = "@nextly/storage-vercel-blob";
|
|
392
|
+
var PACKAGE_VERSION = "0.1.0";
|
|
393
|
+
|
|
394
|
+
exports.PACKAGE_NAME = PACKAGE_NAME;
|
|
395
|
+
exports.PACKAGE_VERSION = PACKAGE_VERSION;
|
|
396
|
+
exports.VercelBlobStorageAdapter = VercelBlobStorageAdapter;
|
|
397
|
+
exports.vercelBlobStorage = vercelBlobStorage;
|
|
398
|
+
//# sourceMappingURL=index.cjs.map
|
|
399
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapter.ts","../src/plugin.ts","../src/index.ts"],"names":["put","del","head","list"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Vercel Blob Storage Adapter\n *\n * Stores files on Vercel Blob Storage, a globally distributed object storage\n * service powered by Cloudflare R2. Optimized for Vercel deployments with\n * built-in CDN distribution.\n *\n * Features:\n * - Globally distributed CDN-backed storage\n * - Automatic file management with unique filenames\n * - Token-based authentication\n * - Client-side upload support (via handleUpload API)\n * - Serverless-friendly (no filesystem access required)\n *\n * @example Basic usage\n * ```typescript\n * const adapter = new VercelBlobStorageAdapter({\n * token: process.env.BLOB_READ_WRITE_TOKEN,\n * collections: { media: true }\n * });\n *\n * const result = await adapter.upload(buffer, {\n * filename: 'photo.jpg',\n * mimeType: 'image/jpeg'\n * });\n * // result.url: \"https://abc123.public.blob.vercel-storage.com/photo-xyz.jpg\"\n * ```\n */\n\nimport { put, del, head, list } from \"@vercel/blob\";\nimport type {\n IStorageAdapter,\n UploadOptions,\n UploadResult,\n StorageAdapterInfo,\n ClientUploadData,\n FileMetadata,\n BulkDeleteResult,\n} from \"nextly/storage\";\n\nimport type {\n VercelBlobStorageConfig,\n ResolvedVercelBlobConfig,\n} from \"./types\";\n\n// ============================================================\n// Vercel Blob Storage Adapter\n// ============================================================\n\n/**\n * Vercel Blob Storage Adapter\n *\n * Implements the IStorageAdapter interface for Vercel Blob Storage.\n * Provides file upload, deletion, existence checks, and metadata retrieval.\n */\nexport class VercelBlobStorageAdapter implements IStorageAdapter {\n private resolvedConfig: ResolvedVercelBlobConfig;\n\n /**\n * Create a new Vercel Blob storage adapter.\n *\n * @param config - Vercel Blob storage configuration\n * @throws Error if token is not provided\n */\n constructor(private config: VercelBlobStorageConfig) {\n // Resolve token from config or environment\n const token = config.token || process.env.BLOB_READ_WRITE_TOKEN || \"\";\n\n if (!token) {\n throw new Error(\n \"@nextly/storage-vercel-blob: token is required.\\n\\n\" +\n \"Either set the BLOB_READ_WRITE_TOKEN environment variable or pass token in config.\\n\" +\n \"Get your token from: Vercel Dashboard > Storage > Blob > Tokens\"\n );\n }\n\n // Resolve config with defaults\n this.resolvedConfig = {\n token,\n addRandomSuffix: config.addRandomSuffix ?? true,\n cacheControlMaxAge: config.cacheControlMaxAge ?? 31536000,\n access: config.access ?? \"public\",\n storeId: config.storeId,\n allowOverwrite: config.allowOverwrite ?? false,\n multipartThreshold: config.multipartThreshold ?? 5 * 1024 * 1024, // 5MB\n };\n }\n\n // ============================================================\n // Core IStorageAdapter Methods\n // ============================================================\n\n /**\n * Upload file to Vercel Blob.\n *\n * Files are stored with globally distributed CDN backing.\n * By default, a random suffix is added to prevent filename collisions.\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 // Hard-reject SVG and HTML uploads on Vercel Blob. The platform\n // does not support per-object response headers (no Content-\n // Disposition: attachment, no CSP), so an attacker who can\n // persuade an admin to upload `evil.svg` (or `.html`) gets a\n // stored XSS that fires on every viewer hit. S3 / R2 / similar\n // adapters can enforce attachment-disposition; Vercel Blob cannot.\n const mimeType = (options.contentType || options.mimeType || \"\")\n .toLowerCase()\n .trim();\n const filename = (options.filename || \"\").toLowerCase();\n const ext = filename.includes(\".\")\n ? filename.slice(filename.lastIndexOf(\".\") + 1)\n : \"\";\n const isSvg =\n mimeType === \"image/svg+xml\" || ext === \"svg\" || ext === \"svgz\";\n const isHtml =\n mimeType === \"text/html\" ||\n mimeType === \"application/xhtml+xml\" ||\n ext === \"html\" ||\n ext === \"htm\" ||\n ext === \"xhtml\";\n if (isSvg || isHtml) {\n const kind = isSvg ? \"SVG\" : \"HTML\";\n throw new Error(\n `[nextly/storage-vercel-blob] ${kind} uploads are rejected on Vercel ` +\n `Blob — the platform cannot serve them with attachment-disposition ` +\n `or a restrictive CSP, so they would be stored XSS. Use the S3 / R2 ` +\n `adapter for ${kind} files, or convert to a raster format (PNG/WebP).`\n );\n }\n\n const pathname = this.buildPathname(options.filename, options.folder);\n\n const result = await put(pathname, buffer, {\n access: this.resolvedConfig.access,\n token: this.resolvedConfig.token,\n contentType: options.contentType || options.mimeType,\n addRandomSuffix: this.resolvedConfig.addRandomSuffix,\n cacheControlMaxAge: this.resolvedConfig.cacheControlMaxAge,\n });\n\n // Vercel Blob uses the URL as the path identifier\n return {\n url: result.url,\n path: result.url,\n };\n }\n\n /**\n * Delete file from Vercel Blob.\n *\n * @param filePath - Full blob URL to delete\n */\n async delete(filePath: string): Promise<void> {\n await del(filePath, {\n token: this.resolvedConfig.token,\n });\n }\n\n /**\n * Delete multiple files from Vercel Blob in chunked batches.\n *\n * Processes deletions in chunks of 10 concurrent calls using Promise.allSettled,\n * collecting successes and failures without short-circuiting.\n *\n * @param filePaths - Array of full blob URLs to delete\n * @returns BulkDeleteResult with successful and failed arrays\n */\n async bulkDelete(filePaths: string[]): Promise<BulkDeleteResult> {\n const successful: string[] = [];\n const failed: Array<{ filePath: string; error: string }> = [];\n const chunkSize = 10;\n\n for (let i = 0; i < filePaths.length; i += chunkSize) {\n const chunk = filePaths.slice(i, i + chunkSize);\n const results = await Promise.allSettled(\n chunk.map(fp => del(fp, { token: this.resolvedConfig.token }))\n );\n\n results.forEach((result, idx) => {\n const fp = chunk[idx];\n if (result.status === \"fulfilled\") {\n successful.push(fp);\n } else {\n failed.push({\n filePath: fp,\n error:\n result.reason instanceof Error\n ? result.reason.message\n : String(result.reason),\n });\n }\n });\n }\n\n return { successful, failed };\n }\n\n /**\n * Check if file exists in Vercel Blob.\n *\n * Uses the head() function which is efficient for existence checks.\n *\n * @param filePath - Full blob URL to check\n * @returns true if file exists, false otherwise\n */\n async exists(filePath: string): Promise<boolean> {\n try {\n await head(filePath, {\n token: this.resolvedConfig.token,\n });\n return true;\n } catch {\n // Vercel Blob throws an error if the blob doesn't exist\n return false;\n }\n }\n\n /**\n * Get public URL for Vercel Blob file.\n *\n * Vercel Blob paths ARE the public URLs - they're already full HTTPS URLs\n * with CDN distribution.\n *\n * @param filePath - Full blob URL\n * @returns The same URL (Vercel Blob paths are already public URLs)\n */\n getPublicUrl(filePath: string): string {\n // Vercel Blob paths are already full URLs\n return filePath;\n }\n\n /**\n * Get storage type identifier.\n */\n getType(): \"vercel-blob\" {\n return \"vercel-blob\";\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: \"vercel-blob\",\n name: \"VercelBlobStorageAdapter\",\n supportsSignedUrls: false, // Vercel Blob URLs are public by default\n supportsClientUploads: true, // Via handleUpload API\n };\n }\n\n /**\n * Get file metadata from Vercel Blob.\n *\n * Retrieves file information including size, content type, and upload date.\n *\n * @param filePath - Full blob URL\n * @returns File metadata or null if file not found\n */\n async getMetadata(filePath: string): Promise<FileMetadata | null> {\n try {\n const result = await head(filePath, {\n token: this.resolvedConfig.token,\n });\n\n // Extract filename from pathname\n const filename = result.pathname.split(\"/\").pop() || result.pathname;\n\n return {\n id: filePath,\n filename,\n originalFilename: filename,\n mimeType: result.contentType,\n size: result.size,\n url: result.url,\n createdAt: result.uploadedAt.toISOString(),\n };\n } catch {\n // File not found\n return null;\n }\n }\n\n /**\n * Generate pre-signed URL for client-side uploads.\n *\n * Vercel Blob uses a different pattern for client uploads - they require\n * the handleUpload API which handles token generation server-side.\n *\n * This method is not implemented because Vercel Blob's client upload flow\n * requires server-side route handlers with handleUpload().\n *\n * @see https://vercel.com/docs/storage/vercel-blob/client-upload\n * @throws Error always - client uploads require handleUpload API\n */\n getPresignedUploadUrl(\n _key: string,\n _mimeType: string,\n _expiresIn?: number\n ): Promise<ClientUploadData> {\n return Promise.reject(\n new Error(\n \"@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\\n\\n\" +\n \"Vercel Blob uses a different pattern for client-side uploads that involves:\\n\" +\n \"1. A server-side route handler with handleUpload()\\n\" +\n \"2. The @vercel/blob/client upload() function\\n\\n\" +\n \"See: https://vercel.com/docs/storage/vercel-blob/client-upload\\n\\n\" +\n \"For server-side uploads, use the standard upload() method instead.\"\n )\n );\n }\n\n // ============================================================\n // Additional Methods\n // ============================================================\n\n /**\n * List blobs with optional prefix filter.\n *\n * Useful for browsing or batch operations on stored files.\n *\n * @param prefix - Optional prefix to filter results\n * @param options - List options (limit, cursor)\n * @returns List of blob metadata\n */\n async listBlobs(\n prefix?: string,\n options?: { limit?: number; cursor?: string }\n ): Promise<{\n blobs: Array<{\n url: string;\n pathname: string;\n size: number;\n uploadedAt: Date;\n }>;\n hasMore: boolean;\n cursor?: string;\n }> {\n const result = await list({\n token: this.resolvedConfig.token,\n prefix,\n limit: options?.limit,\n cursor: options?.cursor,\n });\n\n return {\n blobs: result.blobs.map(blob => ({\n url: blob.url,\n pathname: blob.pathname,\n size: blob.size,\n uploadedAt: blob.uploadedAt,\n })),\n hasMore: result.hasMore,\n cursor: result.cursor,\n };\n }\n\n // ============================================================\n // Helper Methods\n // ============================================================\n\n /**\n * Build pathname for Vercel Blob upload.\n *\n * Creates pathname in format: {folder}/{filename}\n * If no folder is provided, uses the filename directly.\n *\n * @param filename - Original filename (will be sanitized)\n * @param folder - Optional folder/prefix for organizing uploads\n * @returns Generated pathname\n * @throws Error if folder fails sanitization\n */\n private buildPathname(filename: string, folder?: string): string {\n const sanitized = this.sanitizeFilename(filename);\n if (!folder) return sanitized;\n return `${this.sanitizeFolder(folder)}/${sanitized}`;\n }\n\n /**\n * Sanitize filename to prevent path traversal issues.\n *\n * @param filename - Original filename\n * @returns Sanitized filename\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 * Reject folder values that would let a caller\n * escape the configured prefix via path traversal or weird\n * separators. Vercel Blob is flat-keyed so `/` is just part of the\n * pathname, but an attacker who controls `folder` could still:\n *\n * - Use `..` to chain a relative-traversal-style key.\n * - Use `\\` (Windows separator) to confuse downstream tooling.\n * - Use control chars / null bytes to truncate the key.\n * - Use a leading `/` to look like an absolute path.\n *\n * Internal `/` segments stay allowed because callers legitimately\n * use nested folders (`docs/2026/...`); a leading `/`, empty\n * segments (`docs//x`), or `..` segments are rejected.\n *\n * @throws Error with a stable message so the storage facade can\n * surface a 400 instead of a 500.\n */\n private sanitizeFolder(folder: string): string {\n const trimmed = folder.replace(/\\/+$/, \"\"); // tolerate one trailing `/`\n if (!trimmed) {\n throw new Error(\"Folder must be a non-empty string.\");\n }\n // eslint-disable-next-line no-control-regex -- the whole point is to reject control chars\n if (/[\\\\\\0\\x01-\\x1f\\x7f]/.test(trimmed)) {\n throw new Error(\n \"Folder contains forbidden characters (backslash, control chars, or null bytes).\"\n );\n }\n if (trimmed.startsWith(\"/\")) {\n throw new Error(\"Folder must not start with `/`.\");\n }\n const segments = trimmed.split(\"/\");\n for (const segment of segments) {\n if (segment === \"\") {\n throw new Error(\"Folder must not contain empty segments (`//`).\");\n }\n if (segment === \"..\") {\n throw new Error(\"Folder must not contain `..` segments.\");\n }\n }\n return trimmed;\n }\n\n // ============================================================\n // Public Accessors\n // ============================================================\n\n /**\n * Get the configured token (masked for logging).\n */\n getTokenMasked(): string {\n const token = this.resolvedConfig.token;\n if (token.length <= 8) return \"****\";\n return `${token.slice(0, 4)}...${token.slice(-4)}`;\n }\n\n /**\n * Check if random suffix is enabled.\n */\n hasRandomSuffix(): boolean {\n return this.resolvedConfig.addRandomSuffix;\n }\n\n /**\n * Get configured cache control max age.\n */\n getCacheControlMaxAge(): number {\n return this.resolvedConfig.cacheControlMaxAge;\n }\n}\n","/**\n * Vercel Blob Storage Plugin\n *\n * Factory function that creates a storage plugin for Vercel Blob Storage.\n * Returns a StoragePlugin that can be registered with MediaStorage.\n *\n * @example Basic usage\n * ```typescript\n * import { vercelBlobStorage } from '@nextly/storage-vercel-blob'\n * import { defineConfig } from 'nextly/config'\n *\n * export default defineConfig({\n * storage: [\n * vercelBlobStorage({\n * token: process.env.BLOB_READ_WRITE_TOKEN,\n * collections: {\n * media: true\n * }\n * })\n * ]\n * })\n * ```\n *\n * @example With client uploads (recommended for Vercel)\n * ```typescript\n * vercelBlobStorage({\n * collections: {\n * media: {\n * clientUploads: true // Bypass 4.5MB serverless limit\n * }\n * }\n * })\n * ```\n *\n * @example With collection-specific configuration\n * ```typescript\n * vercelBlobStorage({\n * addRandomSuffix: true,\n * cacheControlMaxAge: 86400, // 1 day\n * collections: {\n * // Simple enable with defaults\n * media: true,\n *\n * // Full configuration\n * documents: {\n * prefix: 'docs/',\n * addRandomSuffix: false,\n * allowOverwrite: true\n * }\n * }\n * })\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { StoragePlugin, ClientUploadData } from \"nextly/storage\";\n\nimport { VercelBlobStorageAdapter } from \"./adapter\";\nimport type { VercelBlobStorageConfig } from \"./types\";\n\n// ============================================================\n// Plugin Factory Function\n// ============================================================\n\n/**\n * Create a Vercel Blob 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`. Vercel Blob is optimized\n * for Vercel deployments with:\n *\n * - Global CDN distribution\n * - Simple token-based authentication\n * - Client-side upload support (via handleUpload API)\n * - Automatic file management\n *\n * @param config - Vercel Blob storage configuration\n * @returns A StoragePlugin that MediaStorage can register\n *\n * @throws Error if token is not provided (via adapter)\n */\nexport function vercelBlobStorage(\n config: VercelBlobStorageConfig\n): 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: \"vercel-blob-storage\",\n type: \"vercel-blob\",\n collections: {},\n adapter: null as unknown as StoragePlugin[\"adapter\"],\n };\n }\n\n // Create the Vercel Blob adapter\n // Adapter constructor validates required config (token)\n const adapter = new VercelBlobStorageAdapter(config);\n\n // Build and return the plugin\n const plugin: StoragePlugin = {\n name: \"vercel-blob-storage\",\n type: \"vercel-blob\",\n collections: config.collections,\n adapter,\n\n /**\n * Generate upload URL for client-side uploads.\n *\n * Note: Vercel Blob uses a different pattern for client uploads.\n * Instead of pre-signed URLs, it uses the handleUpload() API which\n * requires server-side route handlers.\n *\n * This method throws an error explaining the correct approach.\n *\n * @see https://vercel.com/docs/storage/vercel-blob/client-upload\n */\n getClientUploadUrl(\n _filename: string,\n _mimeType: string,\n _collection: string\n ): Promise<ClientUploadData> {\n // Vercel Blob doesn't use pre-signed URLs like S3\n // Instead, it requires using the handleUpload API with @vercel/blob/client\n return Promise.reject(\n new Error(\n \"@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\\n\\n\" +\n \"Vercel Blob uses a different pattern than S3 for client-side uploads:\\n\\n\" +\n \"1. Create a server-side route handler with handleUpload()\\n\" +\n \"2. Use upload() from @vercel/blob/client on the frontend\\n\\n\" +\n \"See: https://vercel.com/docs/storage/vercel-blob/client-upload\"\n )\n );\n },\n\n /**\n * Get signed download URL for file access.\n *\n * Note: Vercel Blob URLs are public by default and do not support\n * signed/temporary URLs. All uploaded blobs are accessible via their\n * public URL.\n *\n * @param path - Storage path (full blob URL)\n * @returns The same URL (Vercel Blob URLs are public)\n */\n getSignedDownloadUrl(path: string): Promise<string> {\n // Vercel Blob URLs are public by default\n // No signed URL support - just return the public URL\n return Promise.resolve(path);\n },\n };\n\n return plugin;\n}\n","/**\n * @nextly/storage-vercel-blob\n *\n * Vercel Blob storage adapter for Nextly CMS.\n * Optimized for Vercel deployments with client-side upload support\n * to bypass serverless function size limits (4.5MB).\n *\n * @example Basic usage\n * ```typescript\n * import { vercelBlobStorage } from '@nextly/storage-vercel-blob'\n * import { defineConfig } from 'nextly/config'\n *\n * export default defineConfig({\n * storage: [\n * vercelBlobStorage({\n * token: process.env.BLOB_READ_WRITE_TOKEN,\n * collections: {\n * media: true\n * }\n * })\n * ]\n * })\n * ```\n *\n * @example With client uploads (recommended for Vercel)\n * ```typescript\n * vercelBlobStorage({\n * collections: {\n * media: {\n * clientUploads: true // Bypass 4.5MB serverless limit\n * }\n * }\n * })\n * ```\n *\n * @packageDocumentation\n */\n\n// ============================================================\n// Vercel Blob Storage Plugin Export (Primary API)\n// ============================================================\n\nexport { vercelBlobStorage } from \"./plugin\";\n\n// ============================================================\n// Vercel Blob Storage Adapter Export\n// ============================================================\n\nexport { VercelBlobStorageAdapter } from \"./adapter\";\n\n// ============================================================\n// Vercel Blob Types Export\n// ============================================================\n\nexport type {\n VercelBlobStorageConfig,\n VercelBlobCollectionConfig,\n VercelBlobCollectionStorageMap,\n ResolvedVercelBlobConfig,\n} from \"./types\";\n\n// ============================================================\n// Package Metadata\n// ============================================================\n\nexport const PACKAGE_NAME = \"@nextly/storage-vercel-blob\";\nexport const PACKAGE_VERSION = \"0.1.0\";\n"]}
|