@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.
@@ -0,0 +1,4 @@
1
+
2
+ > @opensaas/stack-storage-vercel@0.1.1 build /home/runner/work/stack/stack/packages/storage-vercel
3
+ > tsc
4
+
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # @opensaas/stack-storage-vercel
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 045c071: Add field and image upload
8
+ - Updated dependencies [045c071]
9
+ - @opensaas/stack-storage@0.1.1
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
@@ -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
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -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
+ })