Skip to content

Commit 8530b45

Browse files
authored
fix(storage-vercel-blob): properly handle alwaysInsertFields and add comprehensive integration test suite with a vercel blob emulator (#16080)
This PR: - Adds Vercel Blob Emulator `ghcr.io/payloadcms/vercel-blob-emulator:latest` from https://github.com/payloadcms/vercel-blob-emulator to our `docker-compose.yml` (same purpose as with `localstack`, `azure-storage` etc.) - Adds comprehensive integration test suite for the vercel blob adapter (before we did not have any), including client uploads. Pretty much copy of our tests for S3, just adapted for vercel SDK instead of AWS S3. - Updates the SDK (`@vercel/blob`) to `2.3.1` - Fixes an issue with `alwaysInsertFields` - previously we did not handle this property at all - the copied tests from S3 caught that. Similar to #15541 - Adds ability to override `baseUrl` with `STORAGE_VERCEL_BLOB_BASE_URL` env variable - required for the emulator so requests don't go to `*.blob.vercel-storage.com`. The emulator is good enough for local developing if you want to avoid locally either switching to local disk storage conditionally or using a vercel blob token.
1 parent c5a3767 commit 8530b45

File tree

18 files changed

+734
-15
lines changed

18 files changed

+734
-15
lines changed

packages/storage-vercel-blob/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
},
4949
"dependencies": {
5050
"@payloadcms/plugin-cloud-storage": "workspace:*",
51-
"@vercel/blob": "^0.22.3"
51+
"@vercel/blob": "2.3.1"
5252
},
5353
"devDependencies": {
5454
"payload": "workspace:*"

packages/storage-vercel-blob/src/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export const vercelBlobStorage: VercelBlobStoragePlugin =
110110
...options,
111111
}
112112

113-
const baseUrl = `https://${storeId}.${optionsWithDefaults.access}.blob.vercel-storage.com`
113+
// support overriding the base URL for emulator https://github.com/payloadcms/vercel-blob-emulator
114+
const baseUrl =
115+
process.env.STORAGE_VERCEL_BLOB_BASE_URL ||
116+
`https://${storeId}.${optionsWithDefaults.access}.blob.vercel-storage.com`
114117

115118
initClientUploads<
116119
VercelBlobClientUploadHandlerExtra,
@@ -138,6 +141,22 @@ export const vercelBlobStorage: VercelBlobStoragePlugin =
138141

139142
// If the plugin is disabled or no token is provided, do not enable the plugin
140143
if (isPluginDisabled) {
144+
if (options.alwaysInsertFields) {
145+
const collectionsWithoutAdapter: CloudStoragePluginOptions['collections'] = Object.entries(
146+
options.collections,
147+
).reduce(
148+
(acc, [slug, collOptions]) => ({
149+
...acc,
150+
[slug]: { ...(collOptions === true ? {} : collOptions), adapter: null },
151+
}),
152+
{} as Record<string, CollectionOptions>,
153+
)
154+
return cloudStoragePlugin({
155+
alwaysInsertFields: true,
156+
collections: collectionsWithoutAdapter,
157+
enabled: false,
158+
})(incomingConfig)
159+
}
141160
return incomingConfig
142161
}
143162

pnpm-lock.yaml

Lines changed: 22 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/docker-clean.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { execSync } from 'child_process'
22

33
try {
44
execSync(
5-
'docker rm -f postgres-payload-test mongodb-payload-test mongot-payload-test mongodb-atlas-payload-test localstack_demo',
5+
'docker rm -f postgres-payload-test mongodb-payload-test mongot-payload-test mongodb-atlas-payload-test localstack_demo vercel-blob',
66
{ stdio: 'ignore' },
77
)
88
} catch {

test/docker-compose.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,20 @@ services:
217217
volumes:
218218
- google_cloud_storage_data:/data
219219

220+
# ── Vercel Blob (vercel-blob-emulator) ──────────────────────
221+
vercel-blob:
222+
profiles: [all, storage]
223+
image: ghcr.io/payloadcms/vercel-blob-emulator:latest
224+
container_name: vercel-blob-payload-test
225+
restart: always
226+
ports:
227+
- '3100:3000'
228+
environment:
229+
BLOB_STORE_ID: emulator
230+
EMULATOR_BASE_URL: 'http://localhost:3100'
231+
volumes:
232+
- vercel_blob_data:/data
233+
220234
volumes:
221235
postgres_data:
222236
postgres_replica_data:
@@ -228,3 +242,4 @@ volumes:
228242
localstack_data:
229243
azurestoragedata:
230244
google_cloud_storage_data:
245+
vercel_blob_data:

test/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"@stripe/stripe-js": "7.3.1",
8282
"@types/react": "19.2.9",
8383
"@types/react-dom": "19.2.3",
84+
"@vercel/blob": "2.3.1",
8485
"babel-plugin-react-compiler": "19.1.0-rc.3",
8586
"better-sqlite3": "11.10.0",
8687
"comment-json": "^4.2.3",

test/plugin-cloud-storage/.env.emulated

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,11 @@ R2_SECRET_ACCESS_KEY=secret-access-key
2727
R2_BUCKET=payload-bucket
2828
R2_FORCE_PATH_STYLE=
2929

30+
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_emulator_test
31+
NEXT_PUBLIC_VERCEL_BLOB_API_URL=http://localhost:3100/api/blob
32+
STORAGE_VERCEL_BLOB_BASE_URL=http://localhost:3100
33+
VERCEL_BLOB_RETRIES=0
34+
VERCEL_BLOB_CALLBACK_URL=http://localhost:3100
35+
3036
PAYLOAD_DROP_DATABASE=true
3137
PAYLOAD_PUBLIC_CLOUD_STORAGE_ADAPTER=s3
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
2+
import dotenv from 'dotenv'
3+
import { fileURLToPath } from 'node:url'
4+
import path from 'path'
5+
6+
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
7+
import { devUser } from '../credentials.js'
8+
import { Media } from './collections/Media.js'
9+
import { MediaWithPrefix } from './collections/MediaWithPrefix.js'
10+
import { Users } from './collections/Users.js'
11+
import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js'
12+
13+
const filename = fileURLToPath(import.meta.url)
14+
const dirname = path.dirname(filename)
15+
16+
dotenv.config({
17+
path: path.resolve(dirname, '../plugin-cloud-storage/.env.emulated'),
18+
})
19+
20+
export default buildConfigWithDefaults({
21+
admin: {
22+
importMap: {
23+
baseDir: path.resolve(dirname),
24+
},
25+
},
26+
collections: [Media, MediaWithPrefix, Users],
27+
onInit: async (payload) => {
28+
await payload.create({
29+
collection: 'users',
30+
data: {
31+
email: devUser.email,
32+
password: devUser.password,
33+
},
34+
})
35+
},
36+
plugins: [
37+
vercelBlobStorage({
38+
clientUploads: {
39+
access: ({ req }) => (req.headers.get('x-disallow-access') ? false : true),
40+
},
41+
collections: {
42+
[mediaSlug]: true,
43+
[mediaWithPrefixSlug]: {
44+
prefix,
45+
},
46+
},
47+
token: process.env.BLOB_READ_WRITE_TOKEN,
48+
}),
49+
],
50+
typescript: {
51+
outputFile: path.resolve(dirname, 'payload-types.ts'),
52+
},
53+
})
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
2+
import type { AddressInfo } from 'node:net'
3+
import type { Payload } from 'payload'
4+
5+
import { del, list } from '@vercel/blob'
6+
import { upload } from '@vercel/blob/client'
7+
import dotenv from 'dotenv'
8+
import { readFileSync } from 'fs'
9+
import { createServer } from 'node:http'
10+
import path from 'path'
11+
import { fileURLToPath } from 'url'
12+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
13+
14+
import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js'
15+
16+
import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
17+
import { prefix } from './shared.js'
18+
19+
const filename = fileURLToPath(import.meta.url)
20+
const dirname = path.dirname(filename)
21+
22+
// Load emulated env vars so @vercel/blob SDK uses the local emulator
23+
dotenv.config({ path: path.resolve(dirname, '../plugin-cloud-storage/.env.emulated') })
24+
25+
let payload: Payload
26+
let restClient: NextRESTClient
27+
let httpServer: Server
28+
let handleUploadUrl: string
29+
30+
const serverHandlerPath = '/vercel-blob-client-upload-route'
31+
32+
describe('@payloadcms/storage-vercel-blob clientUploads', () => {
33+
beforeAll(async () => {
34+
;({ payload, restClient } = await initPayloadInt(
35+
dirname,
36+
undefined,
37+
undefined,
38+
path.resolve(dirname, 'clientUploads.config.ts'),
39+
))
40+
41+
// Start a real HTTP server to bridge upload()'s undici fetch calls to restClient.
42+
// @vercel/blob/client uses undici internally, so a real HTTP server is required.
43+
httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
44+
const chunks: Buffer[] = []
45+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
46+
await new Promise<void>((resolve) => req.on('end', resolve))
47+
48+
const body = Buffer.concat(chunks).toString()
49+
const headers: Record<string, string> = {}
50+
for (const [key, value] of Object.entries(req.headers)) {
51+
if (typeof value === 'string') {
52+
headers[key] = value
53+
}
54+
}
55+
56+
const response = await restClient.POST(serverHandlerPath as `/${string}`, { body, headers })
57+
const responseBody = await response.text()
58+
59+
res.writeHead(response.status, {
60+
'content-type': response.headers.get('content-type') ?? 'application/json',
61+
})
62+
res.end(responseBody)
63+
})
64+
65+
await new Promise<void>((resolve) => httpServer.listen(0, '127.0.0.1', resolve))
66+
const port = (httpServer.address() as AddressInfo).port
67+
handleUploadUrl = `http://127.0.0.1:${port}`
68+
})
69+
70+
afterAll(async () => {
71+
httpServer.close()
72+
await payload.destroy()
73+
})
74+
75+
afterEach(async () => {
76+
const { blobs } = await list()
77+
78+
if (blobs.length > 0) {
79+
await del(blobs.map((b) => b.url))
80+
}
81+
})
82+
83+
it('should upload a file via client upload flow', async () => {
84+
const file = readFileSync(path.resolve(dirname, '../uploads/image.png'))
85+
const pathname = 'image.png'
86+
87+
const result = await upload(pathname, new Blob([file], { type: 'image/png' }), {
88+
access: 'public',
89+
clientPayload: 'media',
90+
contentType: 'image/png',
91+
handleUploadUrl,
92+
})
93+
94+
expect(result.url).toBeDefined()
95+
expect(result.url).toContain(pathname)
96+
97+
const { blobs } = await list()
98+
const uploaded = blobs.find((b) => b.pathname === pathname)
99+
expect(uploaded).toBeDefined()
100+
})
101+
102+
it("should reject upload when 'x-disallow-access' header is set", async () => {
103+
const file = readFileSync(path.resolve(dirname, '../uploads/image.png'))
104+
105+
await expect(
106+
upload('image.png', new Blob([file], { type: 'image/png' }), {
107+
access: 'public',
108+
clientPayload: 'media',
109+
handleUploadUrl,
110+
headers: { 'x-disallow-access': 'true' },
111+
}),
112+
).rejects.toThrow()
113+
})
114+
115+
it('should reject upload when no collection slug is provided', async () => {
116+
const file = readFileSync(path.resolve(dirname, '../uploads/image.png'))
117+
118+
await expect(
119+
upload('image.png', new Blob([file], { type: 'image/png' }), {
120+
access: 'public',
121+
handleUploadUrl,
122+
}),
123+
).rejects.toThrow()
124+
})
125+
126+
it('should upload a file with prefix via client upload flow', async () => {
127+
const file = readFileSync(path.resolve(dirname, '../uploads/image.png'))
128+
const pathname = `${prefix}/image.png`
129+
130+
const result = await upload(pathname, new Blob([file], { type: 'image/png' }), {
131+
access: 'public',
132+
clientPayload: 'media-with-prefix',
133+
contentType: 'image/png',
134+
handleUploadUrl,
135+
})
136+
137+
expect(result.url).toBeDefined()
138+
expect(result.url).toContain(prefix)
139+
expect(result.url).toContain('image.png')
140+
141+
const { blobs } = await list()
142+
const uploaded = blobs.find((b) => b.pathname === pathname)
143+
expect(uploaded).toBeDefined()
144+
})
145+
})

0 commit comments

Comments
 (0)