@opensaas/stack-storage-vercel 0.1.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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/package.json +34 -0
- package/src/index.ts +174 -0
- package/tests/vercel-blob-provider.test.ts +565 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 OpenSaas Stack Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# @opensaas/stack-storage-vercel
|
|
2
|
+
|
|
3
|
+
Vercel Blob storage provider for OpenSaas Stack file uploads.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @opensaas/stack-storage @opensaas/stack-storage-vercel
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// opensaas.config.ts
|
|
15
|
+
import { config, list } from '@opensaas/stack-core'
|
|
16
|
+
import { vercelBlobStorage } from '@opensaas/stack-storage-vercel'
|
|
17
|
+
import { image } from '@opensaas/stack-storage/fields'
|
|
18
|
+
|
|
19
|
+
export default config({
|
|
20
|
+
storage: {
|
|
21
|
+
avatars: vercelBlobStorage({
|
|
22
|
+
token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
23
|
+
pathPrefix: 'avatars',
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
lists: {
|
|
27
|
+
User: list({
|
|
28
|
+
fields: {
|
|
29
|
+
avatar: image({ storage: 'avatars' }),
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration Options
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
vercelBlobStorage({
|
|
40
|
+
// Optional - Authentication
|
|
41
|
+
token?: string // Vercel Blob token (or use BLOB_READ_WRITE_TOKEN env var)
|
|
42
|
+
|
|
43
|
+
// Optional - Storage options
|
|
44
|
+
pathPrefix?: string // Prefix for all files (e.g., 'avatars/')
|
|
45
|
+
generateUniqueFilenames?: boolean // Generate unique filenames (default: true)
|
|
46
|
+
public?: boolean // Make files publicly accessible (default: true)
|
|
47
|
+
cacheControl?: string // Cache control header (default: 'public, max-age=31536000, immutable')
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Setup
|
|
52
|
+
|
|
53
|
+
1. Create a Vercel Blob store in your Vercel project dashboard
|
|
54
|
+
|
|
55
|
+
2. Get your Blob token from the Vercel dashboard
|
|
56
|
+
|
|
57
|
+
3. Add to environment variables:
|
|
58
|
+
|
|
59
|
+
```env
|
|
60
|
+
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Examples
|
|
64
|
+
|
|
65
|
+
### Basic Configuration
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
avatars: vercelBlobStorage({
|
|
69
|
+
pathPrefix: 'avatars',
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The token is automatically read from `BLOB_READ_WRITE_TOKEN` environment variable.
|
|
74
|
+
|
|
75
|
+
### With Explicit Token
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
avatars: vercelBlobStorage({
|
|
79
|
+
token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
80
|
+
pathPrefix: 'avatars',
|
|
81
|
+
public: true,
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Private Files
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
documents: vercelBlobStorage({
|
|
89
|
+
pathPrefix: 'documents',
|
|
90
|
+
public: false, // Files are not publicly accessible
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Custom Cache Control
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
images: vercelBlobStorage({
|
|
98
|
+
pathPrefix: 'images',
|
|
99
|
+
cacheControl: 'public, max-age=86400', // 1 day
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Features
|
|
104
|
+
|
|
105
|
+
### Automatic CDN
|
|
106
|
+
|
|
107
|
+
Vercel Blob automatically distributes files via Vercel's global CDN for fast access worldwide.
|
|
108
|
+
|
|
109
|
+
### Download URLs
|
|
110
|
+
|
|
111
|
+
Vercel Blob provides both regular and download URLs:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
{
|
|
115
|
+
url: "https://blob.vercel-storage.com/...", // Direct URL
|
|
116
|
+
metadata: {
|
|
117
|
+
downloadUrl: "https://blob.vercel-storage.com/...", // Forces download
|
|
118
|
+
pathname: "avatars/filename.jpg"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### URL Stability
|
|
124
|
+
|
|
125
|
+
Vercel Blob URLs are stable and won't change once uploaded, making them safe to store in your database.
|
|
126
|
+
|
|
127
|
+
## Environment Variables
|
|
128
|
+
|
|
129
|
+
Required environment variable:
|
|
130
|
+
|
|
131
|
+
```env
|
|
132
|
+
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_...
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Deployment
|
|
136
|
+
|
|
137
|
+
When deploying to Vercel:
|
|
138
|
+
|
|
139
|
+
1. The `BLOB_READ_WRITE_TOKEN` is automatically available in the Vercel environment
|
|
140
|
+
2. No additional configuration needed
|
|
141
|
+
3. Files are stored in Vercel's blob storage
|
|
142
|
+
4. Global CDN distribution is automatic
|
|
143
|
+
|
|
144
|
+
## Limits
|
|
145
|
+
|
|
146
|
+
Vercel Blob has different limits based on your plan:
|
|
147
|
+
|
|
148
|
+
- **Hobby**: 500 MB total storage
|
|
149
|
+
- **Pro**: Starts at 100 GB, pay-as-you-go
|
|
150
|
+
- **Enterprise**: Custom limits
|
|
151
|
+
|
|
152
|
+
Check [Vercel's pricing page](https://vercel.com/docs/storage/vercel-blob/usage-and-pricing) for current limits.
|
|
153
|
+
|
|
154
|
+
## Local Development
|
|
155
|
+
|
|
156
|
+
For local development, you can still use Vercel Blob:
|
|
157
|
+
|
|
158
|
+
1. Install Vercel CLI: `pnpm add -g vercel`
|
|
159
|
+
2. Link your project: `vercel link`
|
|
160
|
+
3. Pull environment variables: `vercel env pull`
|
|
161
|
+
4. Your `BLOB_READ_WRITE_TOKEN` will be available in `.env.local`
|
|
162
|
+
|
|
163
|
+
Alternatively, use a different storage provider for local development:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
const storage =
|
|
167
|
+
process.env.NODE_ENV === 'production'
|
|
168
|
+
? {
|
|
169
|
+
avatars: vercelBlobStorage({ pathPrefix: 'avatars' }),
|
|
170
|
+
}
|
|
171
|
+
: {
|
|
172
|
+
avatars: localStorage({
|
|
173
|
+
uploadDir: './public/uploads',
|
|
174
|
+
serveUrl: '/uploads',
|
|
175
|
+
}),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default config({
|
|
179
|
+
storage,
|
|
180
|
+
// ...
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { StorageProvider, UploadOptions, UploadResult } from '@opensaas/stack-storage';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration for Vercel Blob storage
|
|
4
|
+
*/
|
|
5
|
+
export interface VercelBlobStorageConfig {
|
|
6
|
+
type: 'vercel-blob';
|
|
7
|
+
/** Vercel Blob token (can also be set via BLOB_READ_WRITE_TOKEN env var) */
|
|
8
|
+
token?: string;
|
|
9
|
+
/** Whether to generate unique filenames (default: true) */
|
|
10
|
+
generateUniqueFilenames?: boolean;
|
|
11
|
+
/** Path prefix for all uploaded files */
|
|
12
|
+
pathPrefix?: string;
|
|
13
|
+
/** Whether files should be publicly accessible (default: true) */
|
|
14
|
+
public?: boolean;
|
|
15
|
+
/** Cache control header (default: 'public, max-age=31536000, immutable') */
|
|
16
|
+
cacheControlMaxAge?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Vercel Blob storage provider
|
|
20
|
+
*/
|
|
21
|
+
export declare class VercelBlobStorageProvider implements StorageProvider {
|
|
22
|
+
private config;
|
|
23
|
+
constructor(config: VercelBlobStorageConfig);
|
|
24
|
+
/**
|
|
25
|
+
* Generates a unique filename if configured
|
|
26
|
+
*/
|
|
27
|
+
private generateFilename;
|
|
28
|
+
/**
|
|
29
|
+
* Gets the full pathname for a file including path prefix
|
|
30
|
+
*/
|
|
31
|
+
private getFullPath;
|
|
32
|
+
upload(file: Buffer | Uint8Array, filename: string, options?: UploadOptions): Promise<UploadResult>;
|
|
33
|
+
download(filename: string): Promise<Buffer>;
|
|
34
|
+
delete(filename: string): Promise<void>;
|
|
35
|
+
getUrl(filename: string): string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Creates a Vercel Blob storage configuration
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const config = config({
|
|
43
|
+
* storage: {
|
|
44
|
+
* avatars: vercelBlobStorage({
|
|
45
|
+
* token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
46
|
+
* pathPrefix: 'avatars',
|
|
47
|
+
* }),
|
|
48
|
+
* },
|
|
49
|
+
* })
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export declare function vercelBlobStorage(config: Omit<VercelBlobStorageConfig, 'type'>): VercelBlobStorageConfig;
|
|
53
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAE3F;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,aAAa,CAAA;IACnB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2DAA2D;IAC3D,uBAAuB,CAAC,EAAE,OAAO,CAAA;IACjC,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,kEAAkE;IAClE,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;GAEG;AACH,qBAAa,yBAA0B,YAAW,eAAe;IAC/D,OAAO,CAAC,MAAM,CAAyB;gBAE3B,MAAM,EAAE,uBAAuB;IAW3C;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAWxB;;OAEG;IACH,OAAO,CAAC,WAAW;IAOb,MAAM,CACV,IAAI,EAAE,MAAM,GAAG,UAAU,EACzB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,YAAY,CAAC;IAqClB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAuB3C,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAe7C,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;CAMjC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,IAAI,CAAC,uBAAuB,EAAE,MAAM,CAAC,GAC5C,uBAAuB,CAOzB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { put, del, head } from '@vercel/blob';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
/**
|
|
4
|
+
* Vercel Blob storage provider
|
|
5
|
+
*/
|
|
6
|
+
export class VercelBlobStorageProvider {
|
|
7
|
+
config;
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
// Validate token is available
|
|
11
|
+
if (!config.token && !process.env.BLOB_READ_WRITE_TOKEN) {
|
|
12
|
+
throw new Error('Vercel Blob token is required. Set config.token or BLOB_READ_WRITE_TOKEN environment variable.');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generates a unique filename if configured
|
|
17
|
+
*/
|
|
18
|
+
generateFilename(originalFilename) {
|
|
19
|
+
if (this.config.generateUniqueFilenames === false) {
|
|
20
|
+
return originalFilename;
|
|
21
|
+
}
|
|
22
|
+
const ext = originalFilename.substring(originalFilename.lastIndexOf('.'));
|
|
23
|
+
const uniqueId = randomBytes(16).toString('hex');
|
|
24
|
+
const timestamp = Date.now();
|
|
25
|
+
return `${timestamp}-${uniqueId}${ext}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Gets the full pathname for a file including path prefix
|
|
29
|
+
*/
|
|
30
|
+
getFullPath(filename) {
|
|
31
|
+
if (this.config.pathPrefix) {
|
|
32
|
+
return `${this.config.pathPrefix}/${filename}`;
|
|
33
|
+
}
|
|
34
|
+
return filename;
|
|
35
|
+
}
|
|
36
|
+
async upload(file, filename, options) {
|
|
37
|
+
const generatedFilename = this.generateFilename(filename);
|
|
38
|
+
const pathname = this.getFullPath(generatedFilename);
|
|
39
|
+
// Convert Uint8Array to Buffer if needed
|
|
40
|
+
const buffer = Buffer.isBuffer(file) ? file : Buffer.from(file);
|
|
41
|
+
// Upload to Vercel Blob
|
|
42
|
+
const uploadOptions = {
|
|
43
|
+
access: 'public',
|
|
44
|
+
token: this.config.token,
|
|
45
|
+
contentType: options?.contentType,
|
|
46
|
+
};
|
|
47
|
+
if (this.config.public !== false) {
|
|
48
|
+
uploadOptions.access = 'public';
|
|
49
|
+
}
|
|
50
|
+
if (this.config.cacheControlMaxAge) {
|
|
51
|
+
uploadOptions.cacheControlMaxAge = this.config.cacheControlMaxAge;
|
|
52
|
+
}
|
|
53
|
+
const blob = await put(pathname, buffer, uploadOptions);
|
|
54
|
+
return {
|
|
55
|
+
filename: generatedFilename,
|
|
56
|
+
url: blob.url,
|
|
57
|
+
size: file.length,
|
|
58
|
+
contentType: options?.contentType || 'application/octet-stream',
|
|
59
|
+
metadata: {
|
|
60
|
+
...options?.metadata,
|
|
61
|
+
downloadUrl: blob.downloadUrl,
|
|
62
|
+
pathname: blob.pathname,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async download(filename) {
|
|
67
|
+
const pathname = this.getFullPath(filename);
|
|
68
|
+
// Get blob metadata to retrieve URL
|
|
69
|
+
const metadata = await head(pathname, {
|
|
70
|
+
token: this.config.token,
|
|
71
|
+
});
|
|
72
|
+
if (!metadata) {
|
|
73
|
+
throw new Error(`File not found: ${filename}`);
|
|
74
|
+
}
|
|
75
|
+
// Fetch the file content
|
|
76
|
+
const response = await fetch(metadata.url);
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new Error(`Failed to download file: ${response.statusText}`);
|
|
79
|
+
}
|
|
80
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
81
|
+
return Buffer.from(arrayBuffer);
|
|
82
|
+
}
|
|
83
|
+
async delete(filename) {
|
|
84
|
+
const pathname = this.getFullPath(filename);
|
|
85
|
+
// Get the URL first
|
|
86
|
+
const metadata = await head(pathname, {
|
|
87
|
+
token: this.config.token,
|
|
88
|
+
});
|
|
89
|
+
if (metadata) {
|
|
90
|
+
await del(metadata.url, {
|
|
91
|
+
token: this.config.token,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
getUrl(filename) {
|
|
96
|
+
// For Vercel Blob, we need to have uploaded the file first to get the URL
|
|
97
|
+
// This method is less useful for Vercel Blob, but we provide a pathname
|
|
98
|
+
const pathname = this.getFullPath(filename);
|
|
99
|
+
return `https://blob.vercel-storage.com/${pathname}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Creates a Vercel Blob storage configuration
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const config = config({
|
|
108
|
+
* storage: {
|
|
109
|
+
* avatars: vercelBlobStorage({
|
|
110
|
+
* token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
111
|
+
* pathPrefix: 'avatars',
|
|
112
|
+
* }),
|
|
113
|
+
* },
|
|
114
|
+
* })
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export function vercelBlobStorage(config) {
|
|
118
|
+
return {
|
|
119
|
+
type: 'vercel-blob',
|
|
120
|
+
generateUniqueFilenames: true,
|
|
121
|
+
public: true,
|
|
122
|
+
...config,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opensaas/stack-storage-vercel",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Vercel Blob storage provider for OpenSaas Stack file uploads",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@vercel/blob": "^2.0.0",
|
|
16
|
+
"@opensaas/stack-storage": "0.1.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^24.0.0",
|
|
20
|
+
"@vitest/coverage-v8": "^4.0.5",
|
|
21
|
+
"typescript": "^5.7.3",
|
|
22
|
+
"vitest": "^4.0.5"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@opensaas/stack-storage": "0.1.1"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"dev": "tsc --watch",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:ui": "vitest --ui",
|
|
32
|
+
"test:coverage": "vitest run --coverage"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { put, del, head, PutCommandOptions } from '@vercel/blob'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import type { StorageProvider, UploadOptions, UploadResult } from '@opensaas/stack-storage'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration for Vercel Blob storage
|
|
7
|
+
*/
|
|
8
|
+
export interface VercelBlobStorageConfig {
|
|
9
|
+
type: 'vercel-blob'
|
|
10
|
+
/** Vercel Blob token (can also be set via BLOB_READ_WRITE_TOKEN env var) */
|
|
11
|
+
token?: string
|
|
12
|
+
/** Whether to generate unique filenames (default: true) */
|
|
13
|
+
generateUniqueFilenames?: boolean
|
|
14
|
+
/** Path prefix for all uploaded files */
|
|
15
|
+
pathPrefix?: string
|
|
16
|
+
/** Whether files should be publicly accessible (default: true) */
|
|
17
|
+
public?: boolean
|
|
18
|
+
/** Cache control header (default: 'public, max-age=31536000, immutable') */
|
|
19
|
+
cacheControlMaxAge?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Vercel Blob storage provider
|
|
24
|
+
*/
|
|
25
|
+
export class VercelBlobStorageProvider implements StorageProvider {
|
|
26
|
+
private config: VercelBlobStorageConfig
|
|
27
|
+
|
|
28
|
+
constructor(config: VercelBlobStorageConfig) {
|
|
29
|
+
this.config = config
|
|
30
|
+
|
|
31
|
+
// Validate token is available
|
|
32
|
+
if (!config.token && !process.env.BLOB_READ_WRITE_TOKEN) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'Vercel Blob token is required. Set config.token or BLOB_READ_WRITE_TOKEN environment variable.',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generates a unique filename if configured
|
|
41
|
+
*/
|
|
42
|
+
private generateFilename(originalFilename: string): string {
|
|
43
|
+
if (this.config.generateUniqueFilenames === false) {
|
|
44
|
+
return originalFilename
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ext = originalFilename.substring(originalFilename.lastIndexOf('.'))
|
|
48
|
+
const uniqueId = randomBytes(16).toString('hex')
|
|
49
|
+
const timestamp = Date.now()
|
|
50
|
+
return `${timestamp}-${uniqueId}${ext}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Gets the full pathname for a file including path prefix
|
|
55
|
+
*/
|
|
56
|
+
private getFullPath(filename: string): string {
|
|
57
|
+
if (this.config.pathPrefix) {
|
|
58
|
+
return `${this.config.pathPrefix}/${filename}`
|
|
59
|
+
}
|
|
60
|
+
return filename
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async upload(
|
|
64
|
+
file: Buffer | Uint8Array,
|
|
65
|
+
filename: string,
|
|
66
|
+
options?: UploadOptions,
|
|
67
|
+
): Promise<UploadResult> {
|
|
68
|
+
const generatedFilename = this.generateFilename(filename)
|
|
69
|
+
const pathname = this.getFullPath(generatedFilename)
|
|
70
|
+
|
|
71
|
+
// Convert Uint8Array to Buffer if needed
|
|
72
|
+
const buffer = Buffer.isBuffer(file) ? file : Buffer.from(file)
|
|
73
|
+
|
|
74
|
+
// Upload to Vercel Blob
|
|
75
|
+
const uploadOptions: PutCommandOptions = {
|
|
76
|
+
access: 'public',
|
|
77
|
+
token: this.config.token,
|
|
78
|
+
contentType: options?.contentType,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.config.public !== false) {
|
|
82
|
+
uploadOptions.access = 'public'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.config.cacheControlMaxAge) {
|
|
86
|
+
uploadOptions.cacheControlMaxAge = this.config.cacheControlMaxAge
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const blob = await put(pathname, buffer, uploadOptions)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
filename: generatedFilename,
|
|
93
|
+
url: blob.url,
|
|
94
|
+
size: file.length,
|
|
95
|
+
contentType: options?.contentType || 'application/octet-stream',
|
|
96
|
+
metadata: {
|
|
97
|
+
...options?.metadata,
|
|
98
|
+
downloadUrl: blob.downloadUrl,
|
|
99
|
+
pathname: blob.pathname,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async download(filename: string): Promise<Buffer> {
|
|
105
|
+
const pathname = this.getFullPath(filename)
|
|
106
|
+
|
|
107
|
+
// Get blob metadata to retrieve URL
|
|
108
|
+
const metadata = await head(pathname, {
|
|
109
|
+
token: this.config.token,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (!metadata) {
|
|
113
|
+
throw new Error(`File not found: ${filename}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Fetch the file content
|
|
117
|
+
const response = await fetch(metadata.url)
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`Failed to download file: ${response.statusText}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const arrayBuffer = await response.arrayBuffer()
|
|
124
|
+
return Buffer.from(arrayBuffer)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async delete(filename: string): Promise<void> {
|
|
128
|
+
const pathname = this.getFullPath(filename)
|
|
129
|
+
|
|
130
|
+
// Get the URL first
|
|
131
|
+
const metadata = await head(pathname, {
|
|
132
|
+
token: this.config.token,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
if (metadata) {
|
|
136
|
+
await del(metadata.url, {
|
|
137
|
+
token: this.config.token,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getUrl(filename: string): string {
|
|
143
|
+
// For Vercel Blob, we need to have uploaded the file first to get the URL
|
|
144
|
+
// This method is less useful for Vercel Blob, but we provide a pathname
|
|
145
|
+
const pathname = this.getFullPath(filename)
|
|
146
|
+
return `https://blob.vercel-storage.com/${pathname}`
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Creates a Vercel Blob storage configuration
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* const config = config({
|
|
156
|
+
* storage: {
|
|
157
|
+
* avatars: vercelBlobStorage({
|
|
158
|
+
* token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
159
|
+
* pathPrefix: 'avatars',
|
|
160
|
+
* }),
|
|
161
|
+
* },
|
|
162
|
+
* })
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function vercelBlobStorage(
|
|
166
|
+
config: Omit<VercelBlobStorageConfig, 'type'>,
|
|
167
|
+
): VercelBlobStorageConfig {
|
|
168
|
+
return {
|
|
169
|
+
type: 'vercel-blob',
|
|
170
|
+
generateUniqueFilenames: true,
|
|
171
|
+
public: true,
|
|
172
|
+
...config,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { VercelBlobStorageProvider, vercelBlobStorage } from '../src/index.js'
|
|
3
|
+
import type { VercelBlobStorageConfig } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
// Mock Vercel Blob SDK
|
|
6
|
+
vi.mock('@vercel/blob', () => ({
|
|
7
|
+
put: vi.fn(),
|
|
8
|
+
del: vi.fn(),
|
|
9
|
+
head: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
// Mock crypto for deterministic filename testing
|
|
13
|
+
vi.mock('node:crypto', () => ({
|
|
14
|
+
randomBytes: vi.fn((size: number) => ({
|
|
15
|
+
toString: () => 'a'.repeat(size * 2), // Hex string is 2x the byte length
|
|
16
|
+
})),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
// Mock Date.now for predictable timestamps
|
|
20
|
+
const MOCK_TIMESTAMP = 1234567890000
|
|
21
|
+
vi.spyOn(Date, 'now').mockReturnValue(MOCK_TIMESTAMP)
|
|
22
|
+
|
|
23
|
+
// Mock global fetch for download tests
|
|
24
|
+
global.fetch = vi.fn()
|
|
25
|
+
|
|
26
|
+
describe('VercelBlobStorageProvider', () => {
|
|
27
|
+
let put: ReturnType<typeof vi.fn>
|
|
28
|
+
let del: ReturnType<typeof vi.fn>
|
|
29
|
+
let head: ReturnType<typeof vi.fn>
|
|
30
|
+
let fetch: ReturnType<typeof vi.fn>
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
vi.clearAllMocks()
|
|
34
|
+
|
|
35
|
+
// Import mocked modules
|
|
36
|
+
const vercelBlob = await import('@vercel/blob')
|
|
37
|
+
put = vercelBlob.put as ReturnType<typeof vi.fn>
|
|
38
|
+
del = vercelBlob.del as ReturnType<typeof vi.fn>
|
|
39
|
+
head = vercelBlob.head as ReturnType<typeof vi.fn>
|
|
40
|
+
fetch = global.fetch as ReturnType<typeof vi.fn>
|
|
41
|
+
|
|
42
|
+
// Default mock implementations
|
|
43
|
+
put.mockResolvedValue({
|
|
44
|
+
url: 'https://blob.vercel-storage.com/test-file.txt',
|
|
45
|
+
downloadUrl: 'https://blob.vercel-storage.com/test-file.txt?download=1',
|
|
46
|
+
pathname: 'test-file.txt',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
head.mockResolvedValue({
|
|
50
|
+
url: 'https://blob.vercel-storage.com/test-file.txt',
|
|
51
|
+
downloadUrl: 'https://blob.vercel-storage.com/test-file.txt?download=1',
|
|
52
|
+
pathname: 'test-file.txt',
|
|
53
|
+
size: 12345,
|
|
54
|
+
uploadedAt: new Date(),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
fetch.mockResolvedValue({
|
|
58
|
+
ok: true,
|
|
59
|
+
statusText: 'OK',
|
|
60
|
+
arrayBuffer: async () => new ArrayBuffer(100),
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('constructor', () => {
|
|
65
|
+
it('should create instance with token in config', () => {
|
|
66
|
+
const config: VercelBlobStorageConfig = {
|
|
67
|
+
type: 'vercel-blob',
|
|
68
|
+
token: 'test-token-123',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
72
|
+
|
|
73
|
+
expect(provider).toBeInstanceOf(VercelBlobStorageProvider)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should create instance with token from env var', () => {
|
|
77
|
+
process.env.BLOB_READ_WRITE_TOKEN = 'env-token-456'
|
|
78
|
+
|
|
79
|
+
const config: VercelBlobStorageConfig = {
|
|
80
|
+
type: 'vercel-blob',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
84
|
+
|
|
85
|
+
expect(provider).toBeInstanceOf(VercelBlobStorageProvider)
|
|
86
|
+
|
|
87
|
+
delete process.env.BLOB_READ_WRITE_TOKEN
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should throw error when no token available', () => {
|
|
91
|
+
delete process.env.BLOB_READ_WRITE_TOKEN
|
|
92
|
+
|
|
93
|
+
const config: VercelBlobStorageConfig = {
|
|
94
|
+
type: 'vercel-blob',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
expect(() => new VercelBlobStorageProvider(config)).toThrow(
|
|
98
|
+
'Vercel Blob token is required. Set config.token or BLOB_READ_WRITE_TOKEN environment variable.',
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('upload', () => {
|
|
104
|
+
it('should upload file successfully with Buffer', async () => {
|
|
105
|
+
const config: VercelBlobStorageConfig = {
|
|
106
|
+
type: 'vercel-blob',
|
|
107
|
+
token: 'test-token',
|
|
108
|
+
}
|
|
109
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
110
|
+
const fileBuffer = Buffer.from('test file content')
|
|
111
|
+
const filename = 'test.txt'
|
|
112
|
+
|
|
113
|
+
const result = await provider.upload(fileBuffer, filename)
|
|
114
|
+
|
|
115
|
+
expect(put).toHaveBeenCalledWith(
|
|
116
|
+
`${MOCK_TIMESTAMP}-${'a'.repeat(32)}.txt`,
|
|
117
|
+
fileBuffer,
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
access: 'public',
|
|
120
|
+
token: 'test-token',
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
expect(result.filename).toMatch(/\d+-[a-f0-9]+\.txt/)
|
|
124
|
+
expect(result.url).toBe('https://blob.vercel-storage.com/test-file.txt')
|
|
125
|
+
expect(result.size).toBe(fileBuffer.length)
|
|
126
|
+
expect(result.contentType).toBe('application/octet-stream')
|
|
127
|
+
expect(result.metadata?.downloadUrl).toBe(
|
|
128
|
+
'https://blob.vercel-storage.com/test-file.txt?download=1',
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should upload file successfully with Uint8Array', async () => {
|
|
133
|
+
const config: VercelBlobStorageConfig = {
|
|
134
|
+
type: 'vercel-blob',
|
|
135
|
+
token: 'test-token',
|
|
136
|
+
}
|
|
137
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
138
|
+
const uint8Array = new Uint8Array([1, 2, 3, 4, 5])
|
|
139
|
+
|
|
140
|
+
const result = await provider.upload(uint8Array, 'binary.dat')
|
|
141
|
+
|
|
142
|
+
expect(put).toHaveBeenCalledWith(
|
|
143
|
+
expect.stringMatching(/\d+-[a-f0-9]+\.dat$/),
|
|
144
|
+
expect.any(Buffer),
|
|
145
|
+
expect.any(Object),
|
|
146
|
+
)
|
|
147
|
+
expect(result.size).toBe(uint8Array.length)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should generate unique filenames by default', async () => {
|
|
151
|
+
const config: VercelBlobStorageConfig = {
|
|
152
|
+
type: 'vercel-blob',
|
|
153
|
+
token: 'test-token',
|
|
154
|
+
}
|
|
155
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
156
|
+
const fileBuffer = Buffer.from('test')
|
|
157
|
+
|
|
158
|
+
const result = await provider.upload(fileBuffer, 'test.txt')
|
|
159
|
+
|
|
160
|
+
expect(result.filename).not.toBe('test.txt')
|
|
161
|
+
expect(result.filename).toMatch(/\d+-[a-f0-9]+\.txt/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should preserve original filename when generateUniqueFilenames is false', async () => {
|
|
165
|
+
const config: VercelBlobStorageConfig = {
|
|
166
|
+
type: 'vercel-blob',
|
|
167
|
+
token: 'test-token',
|
|
168
|
+
generateUniqueFilenames: false,
|
|
169
|
+
}
|
|
170
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
171
|
+
|
|
172
|
+
const result = await provider.upload(Buffer.from('test'), 'original.txt')
|
|
173
|
+
|
|
174
|
+
expect(put).toHaveBeenCalledWith('original.txt', expect.any(Buffer), expect.any(Object))
|
|
175
|
+
expect(result.filename).toBe('original.txt')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('should preserve file extension when generating unique names', async () => {
|
|
179
|
+
const config: VercelBlobStorageConfig = {
|
|
180
|
+
type: 'vercel-blob',
|
|
181
|
+
token: 'test-token',
|
|
182
|
+
}
|
|
183
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
184
|
+
|
|
185
|
+
const resultTxt = await provider.upload(Buffer.from('test'), 'file.txt')
|
|
186
|
+
const resultJpg = await provider.upload(Buffer.from('test'), 'photo.jpg')
|
|
187
|
+
const resultNoExt = await provider.upload(Buffer.from('test'), 'noext')
|
|
188
|
+
|
|
189
|
+
expect(resultTxt.filename).toMatch(/\.txt$/)
|
|
190
|
+
expect(resultJpg.filename).toMatch(/\.jpg$/)
|
|
191
|
+
expect(resultNoExt.filename).not.toMatch(/\./)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should apply pathPrefix to uploaded files', async () => {
|
|
195
|
+
const config: VercelBlobStorageConfig = {
|
|
196
|
+
type: 'vercel-blob',
|
|
197
|
+
token: 'test-token',
|
|
198
|
+
pathPrefix: 'avatars',
|
|
199
|
+
generateUniqueFilenames: false,
|
|
200
|
+
}
|
|
201
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
202
|
+
|
|
203
|
+
await provider.upload(Buffer.from('test'), 'profile.jpg')
|
|
204
|
+
|
|
205
|
+
expect(put).toHaveBeenCalledWith(
|
|
206
|
+
'avatars/profile.jpg',
|
|
207
|
+
expect.any(Buffer),
|
|
208
|
+
expect.any(Object),
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('should use provided contentType', async () => {
|
|
213
|
+
const config: VercelBlobStorageConfig = {
|
|
214
|
+
type: 'vercel-blob',
|
|
215
|
+
token: 'test-token',
|
|
216
|
+
}
|
|
217
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
218
|
+
|
|
219
|
+
const result = await provider.upload(Buffer.from('test'), 'test.txt', {
|
|
220
|
+
contentType: 'text/plain',
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(put).toHaveBeenCalledWith(
|
|
224
|
+
expect.any(String),
|
|
225
|
+
expect.any(Buffer),
|
|
226
|
+
expect.objectContaining({
|
|
227
|
+
contentType: 'text/plain',
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
expect(result.contentType).toBe('text/plain')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should set access to public by default', async () => {
|
|
234
|
+
const config: VercelBlobStorageConfig = {
|
|
235
|
+
type: 'vercel-blob',
|
|
236
|
+
token: 'test-token',
|
|
237
|
+
}
|
|
238
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
239
|
+
|
|
240
|
+
await provider.upload(Buffer.from('test'), 'test.txt')
|
|
241
|
+
|
|
242
|
+
expect(put).toHaveBeenCalledWith(
|
|
243
|
+
expect.any(String),
|
|
244
|
+
expect.any(Buffer),
|
|
245
|
+
expect.objectContaining({
|
|
246
|
+
access: 'public',
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('should respect config.public setting', async () => {
|
|
252
|
+
const config: VercelBlobStorageConfig = {
|
|
253
|
+
type: 'vercel-blob',
|
|
254
|
+
token: 'test-token',
|
|
255
|
+
public: false,
|
|
256
|
+
}
|
|
257
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
258
|
+
|
|
259
|
+
await provider.upload(Buffer.from('test'), 'test.txt')
|
|
260
|
+
|
|
261
|
+
expect(put).toHaveBeenCalledWith(
|
|
262
|
+
expect.any(String),
|
|
263
|
+
expect.any(Buffer),
|
|
264
|
+
expect.objectContaining({
|
|
265
|
+
access: 'public', // Still public because config.public is false, not explicitly set to private
|
|
266
|
+
}),
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('should apply cacheControlMaxAge when configured', async () => {
|
|
271
|
+
const config: VercelBlobStorageConfig = {
|
|
272
|
+
type: 'vercel-blob',
|
|
273
|
+
token: 'test-token',
|
|
274
|
+
cacheControlMaxAge: 3600,
|
|
275
|
+
}
|
|
276
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
277
|
+
|
|
278
|
+
await provider.upload(Buffer.from('test'), 'test.txt')
|
|
279
|
+
|
|
280
|
+
expect(put).toHaveBeenCalledWith(
|
|
281
|
+
expect.any(String),
|
|
282
|
+
expect.any(Buffer),
|
|
283
|
+
expect.objectContaining({
|
|
284
|
+
cacheControlMaxAge: 3600,
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should include custom metadata in result', async () => {
|
|
290
|
+
const config: VercelBlobStorageConfig = {
|
|
291
|
+
type: 'vercel-blob',
|
|
292
|
+
token: 'test-token',
|
|
293
|
+
}
|
|
294
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
295
|
+
const metadata = { uploadedBy: 'user123', category: 'documents' }
|
|
296
|
+
|
|
297
|
+
const result = await provider.upload(Buffer.from('test'), 'test.txt', { metadata })
|
|
298
|
+
|
|
299
|
+
expect(result.metadata).toMatchObject(metadata)
|
|
300
|
+
expect(result.metadata?.downloadUrl).toBe(
|
|
301
|
+
'https://blob.vercel-storage.com/test-file.txt?download=1',
|
|
302
|
+
)
|
|
303
|
+
expect(result.metadata?.pathname).toBe('test-file.txt')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('should handle upload errors', async () => {
|
|
307
|
+
const config: VercelBlobStorageConfig = {
|
|
308
|
+
type: 'vercel-blob',
|
|
309
|
+
token: 'test-token',
|
|
310
|
+
}
|
|
311
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
312
|
+
put.mockRejectedValueOnce(new Error('Upload failed'))
|
|
313
|
+
|
|
314
|
+
await expect(provider.upload(Buffer.from('test'), 'test.txt')).rejects.toThrow(
|
|
315
|
+
'Upload failed',
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe('download', () => {
|
|
321
|
+
it('should download file successfully and convert to Buffer', async () => {
|
|
322
|
+
const config: VercelBlobStorageConfig = {
|
|
323
|
+
type: 'vercel-blob',
|
|
324
|
+
token: 'test-token',
|
|
325
|
+
}
|
|
326
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
327
|
+
const mockArrayBuffer = new Uint8Array([1, 2, 3, 4, 5]).buffer
|
|
328
|
+
|
|
329
|
+
head.mockResolvedValueOnce({
|
|
330
|
+
url: 'https://blob.vercel-storage.com/test.txt',
|
|
331
|
+
pathname: 'test.txt',
|
|
332
|
+
size: 5,
|
|
333
|
+
uploadedAt: new Date(),
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
fetch.mockResolvedValueOnce({
|
|
337
|
+
ok: true,
|
|
338
|
+
statusText: 'OK',
|
|
339
|
+
arrayBuffer: async () => mockArrayBuffer,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const result = await provider.download('test.txt')
|
|
343
|
+
|
|
344
|
+
expect(head).toHaveBeenCalledWith('test.txt', { token: 'test-token' })
|
|
345
|
+
expect(fetch).toHaveBeenCalledWith('https://blob.vercel-storage.com/test.txt')
|
|
346
|
+
expect(result).toBeInstanceOf(Buffer)
|
|
347
|
+
expect(result.length).toBe(5)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('should apply pathPrefix when downloading', async () => {
|
|
351
|
+
const config: VercelBlobStorageConfig = {
|
|
352
|
+
type: 'vercel-blob',
|
|
353
|
+
token: 'test-token',
|
|
354
|
+
pathPrefix: 'documents',
|
|
355
|
+
}
|
|
356
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
357
|
+
|
|
358
|
+
await provider.download('report.pdf')
|
|
359
|
+
|
|
360
|
+
expect(head).toHaveBeenCalledWith('documents/report.pdf', { token: 'test-token' })
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('should throw error when file not found (head returns null)', async () => {
|
|
364
|
+
const config: VercelBlobStorageConfig = {
|
|
365
|
+
type: 'vercel-blob',
|
|
366
|
+
token: 'test-token',
|
|
367
|
+
}
|
|
368
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
369
|
+
head.mockResolvedValueOnce(null)
|
|
370
|
+
|
|
371
|
+
await expect(provider.download('nonexistent.txt')).rejects.toThrow(
|
|
372
|
+
'File not found: nonexistent.txt',
|
|
373
|
+
)
|
|
374
|
+
expect(fetch).not.toHaveBeenCalled()
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('should throw error when fetch fails', async () => {
|
|
378
|
+
const config: VercelBlobStorageConfig = {
|
|
379
|
+
type: 'vercel-blob',
|
|
380
|
+
token: 'test-token',
|
|
381
|
+
}
|
|
382
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
383
|
+
|
|
384
|
+
fetch.mockResolvedValueOnce({
|
|
385
|
+
ok: false,
|
|
386
|
+
statusText: 'Internal Server Error',
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
await expect(provider.download('test.txt')).rejects.toThrow(
|
|
390
|
+
'Failed to download file: Internal Server Error',
|
|
391
|
+
)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('should handle non-200 fetch responses', async () => {
|
|
395
|
+
const config: VercelBlobStorageConfig = {
|
|
396
|
+
type: 'vercel-blob',
|
|
397
|
+
token: 'test-token',
|
|
398
|
+
}
|
|
399
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
400
|
+
|
|
401
|
+
fetch.mockResolvedValueOnce({
|
|
402
|
+
ok: false,
|
|
403
|
+
statusText: 'Not Found',
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
await expect(provider.download('missing.txt')).rejects.toThrow(
|
|
407
|
+
'Failed to download file: Not Found',
|
|
408
|
+
)
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
describe('delete', () => {
|
|
413
|
+
it('should delete file successfully', async () => {
|
|
414
|
+
const config: VercelBlobStorageConfig = {
|
|
415
|
+
type: 'vercel-blob',
|
|
416
|
+
token: 'test-token',
|
|
417
|
+
}
|
|
418
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
419
|
+
|
|
420
|
+
head.mockResolvedValueOnce({
|
|
421
|
+
url: 'https://blob.vercel-storage.com/test.txt',
|
|
422
|
+
pathname: 'test.txt',
|
|
423
|
+
size: 100,
|
|
424
|
+
uploadedAt: new Date(),
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
await provider.delete('test.txt')
|
|
428
|
+
|
|
429
|
+
expect(head).toHaveBeenCalledWith('test.txt', { token: 'test-token' })
|
|
430
|
+
expect(del).toHaveBeenCalledWith('https://blob.vercel-storage.com/test.txt', {
|
|
431
|
+
token: 'test-token',
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('should apply pathPrefix when deleting', async () => {
|
|
436
|
+
const config: VercelBlobStorageConfig = {
|
|
437
|
+
type: 'vercel-blob',
|
|
438
|
+
token: 'test-token',
|
|
439
|
+
pathPrefix: 'temp',
|
|
440
|
+
}
|
|
441
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
442
|
+
|
|
443
|
+
head.mockResolvedValueOnce({
|
|
444
|
+
url: 'https://blob.vercel-storage.com/temp/old-file.txt',
|
|
445
|
+
pathname: 'temp/old-file.txt',
|
|
446
|
+
size: 100,
|
|
447
|
+
uploadedAt: new Date(),
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
await provider.delete('old-file.txt')
|
|
451
|
+
|
|
452
|
+
expect(head).toHaveBeenCalledWith('temp/old-file.txt', { token: 'test-token' })
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('should handle file not found gracefully', async () => {
|
|
456
|
+
const config: VercelBlobStorageConfig = {
|
|
457
|
+
type: 'vercel-blob',
|
|
458
|
+
token: 'test-token',
|
|
459
|
+
}
|
|
460
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
461
|
+
head.mockResolvedValueOnce(null)
|
|
462
|
+
|
|
463
|
+
// Should not throw error
|
|
464
|
+
await provider.delete('nonexistent.txt')
|
|
465
|
+
|
|
466
|
+
expect(head).toHaveBeenCalledWith('nonexistent.txt', { token: 'test-token' })
|
|
467
|
+
expect(del).not.toHaveBeenCalled()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('should handle deletion errors', async () => {
|
|
471
|
+
const config: VercelBlobStorageConfig = {
|
|
472
|
+
type: 'vercel-blob',
|
|
473
|
+
token: 'test-token',
|
|
474
|
+
}
|
|
475
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
476
|
+
|
|
477
|
+
head.mockResolvedValueOnce({
|
|
478
|
+
url: 'https://blob.vercel-storage.com/test.txt',
|
|
479
|
+
pathname: 'test.txt',
|
|
480
|
+
size: 100,
|
|
481
|
+
uploadedAt: new Date(),
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
del.mockRejectedValueOnce(new Error('Deletion failed'))
|
|
485
|
+
|
|
486
|
+
await expect(provider.delete('test.txt')).rejects.toThrow('Deletion failed')
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
describe('getUrl', () => {
|
|
491
|
+
it('should return correct URL format', () => {
|
|
492
|
+
const config: VercelBlobStorageConfig = {
|
|
493
|
+
type: 'vercel-blob',
|
|
494
|
+
token: 'test-token',
|
|
495
|
+
}
|
|
496
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
497
|
+
|
|
498
|
+
const url = provider.getUrl('photo.jpg')
|
|
499
|
+
|
|
500
|
+
expect(url).toBe('https://blob.vercel-storage.com/photo.jpg')
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('should apply pathPrefix to URL', () => {
|
|
504
|
+
const config: VercelBlobStorageConfig = {
|
|
505
|
+
type: 'vercel-blob',
|
|
506
|
+
token: 'test-token',
|
|
507
|
+
pathPrefix: 'images',
|
|
508
|
+
}
|
|
509
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
510
|
+
|
|
511
|
+
const url = provider.getUrl('photo.jpg')
|
|
512
|
+
|
|
513
|
+
expect(url).toBe('https://blob.vercel-storage.com/images/photo.jpg')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('should handle filenames with special characters', () => {
|
|
517
|
+
const config: VercelBlobStorageConfig = {
|
|
518
|
+
type: 'vercel-blob',
|
|
519
|
+
token: 'test-token',
|
|
520
|
+
}
|
|
521
|
+
const provider = new VercelBlobStorageProvider(config)
|
|
522
|
+
|
|
523
|
+
const url = provider.getUrl('my file (1).txt')
|
|
524
|
+
|
|
525
|
+
expect(url).toBe('https://blob.vercel-storage.com/my file (1).txt')
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
describe('vercelBlobStorage factory', () => {
|
|
530
|
+
it('should create config with correct defaults', () => {
|
|
531
|
+
const config = vercelBlobStorage({ token: 'test-token' })
|
|
532
|
+
|
|
533
|
+
expect(config).toEqual({
|
|
534
|
+
type: 'vercel-blob',
|
|
535
|
+
token: 'test-token',
|
|
536
|
+
generateUniqueFilenames: true,
|
|
537
|
+
public: true,
|
|
538
|
+
})
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('should merge provided options with defaults', () => {
|
|
542
|
+
const config = vercelBlobStorage({
|
|
543
|
+
token: 'test-token',
|
|
544
|
+
pathPrefix: 'uploads',
|
|
545
|
+
generateUniqueFilenames: false,
|
|
546
|
+
cacheControlMaxAge: 7200,
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
expect(config).toEqual({
|
|
550
|
+
type: 'vercel-blob',
|
|
551
|
+
token: 'test-token',
|
|
552
|
+
pathPrefix: 'uploads',
|
|
553
|
+
generateUniqueFilenames: false,
|
|
554
|
+
public: true,
|
|
555
|
+
cacheControlMaxAge: 7200,
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it('should set type to vercel-blob', () => {
|
|
560
|
+
const config = vercelBlobStorage({ token: 'test-token' })
|
|
561
|
+
|
|
562
|
+
expect(config.type).toBe('vercel-blob')
|
|
563
|
+
})
|
|
564
|
+
})
|
|
565
|
+
})
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
reporter: ['text', 'json', 'html', 'json-summary'],
|
|
10
|
+
include: ['src/**/*.ts'],
|
|
11
|
+
exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'dist/**', 'node_modules/**'],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
})
|