feat(plugin-cloud-storage): vercel blob storage adapter

This commit is contained in:
Elliot DeNolf
2024-04-11 22:58:55 -04:00
parent c70dcb6a59
commit b51b519d30
8 changed files with 302 additions and 1 deletions

View File

@@ -43,6 +43,9 @@
},
"@google-cloud/storage": {
"optional": true
},
"@vercel/blob": {
"optional": true
}
},
"files": [
@@ -56,6 +59,7 @@
"@azure/storage-blob": "^12.11.0",
"@google-cloud/storage": "^7.7.0",
"@types/find-node-modules": "^2.1.2",
"@vercel/blob": "^0.22.3",
"payload": "workspace:*"
},
"dependencies": {

View File

@@ -0,0 +1,14 @@
import type { GenerateURL } from '@payloadcms/plugin-cloud-storage/types'
import path from 'path'
type GenerateUrlArgs = {
baseUrl: string
prefix?: string
}
export const getGenerateUrl = ({ baseUrl }: GenerateUrlArgs): GenerateURL => {
return ({ filename, prefix = '' }) => {
return `${baseUrl}/${path.posix.join(prefix, filename)}`
}
}

View File

@@ -0,0 +1,32 @@
import type { CollectionConfig, PayloadRequest, UploadConfig } from 'payload/types'
export async function getFilePrefix({
collection,
req,
}: {
collection: CollectionConfig
req: PayloadRequest
}): Promise<string> {
const imageSizes = (collection?.upload as UploadConfig)?.imageSizes || []
const { routeParams } = req
const filename = routeParams?.['filename']
const files = await req.payload.find({
collection: collection.slug,
depth: 0,
limit: 1,
pagination: false,
where: {
or: [
{
filename: { equals: filename },
},
...imageSizes.map((imageSize) => ({
[`sizes.${imageSize.name}.filename`]: { equals: filename },
})),
],
},
})
const prefix = files?.docs?.[0]?.prefix
return prefix ? (prefix as string) : ''
}

View File

@@ -0,0 +1,19 @@
import type { HandleDelete } from '@payloadcms/plugin-cloud-storage/types'
import { del } from '@vercel/blob'
import path from 'path'
type HandleDeleteArgs = {
baseUrl: string
prefix?: string
token: string
}
export const getHandleDelete = ({ baseUrl, token }: HandleDeleteArgs): HandleDelete => {
return async ({ doc: { prefix = '' }, filename }) => {
const fileUrl = `${baseUrl}/${path.posix.join(prefix, filename)}`
const deletedBlob = await del(fileUrl, { token })
return deletedBlob
}
}

View File

@@ -0,0 +1,40 @@
import type { HandleUpload } from '@payloadcms/plugin-cloud-storage/types'
import { put } from '@vercel/blob'
import path from 'path'
import type { VercelBlobAdapterUploadOptions } from './index.js'
type HandleUploadArgs = VercelBlobAdapterUploadOptions & {
baseUrl: string
prefix?: string
token: string
}
export const getHandleUpload = ({
access = 'public',
addRandomSuffix,
baseUrl,
cacheControlMaxAge,
prefix = '',
token,
}: HandleUploadArgs): HandleUpload => {
return async ({ data, file: { buffer, filename, mimeType } }) => {
const fileKey = path.posix.join(data.prefix || prefix, filename)
const result = await put(fileKey, buffer, {
access,
addRandomSuffix,
cacheControlMaxAge,
contentType: mimeType,
token,
})
// Get filename with suffix from returned url
if (addRandomSuffix) {
data.filename = result.url.replace(`${baseUrl}/`, '')
}
return data
}
}

View File

@@ -0,0 +1,82 @@
import type { Adapter, GeneratedAdapter } from '@payloadcms/plugin-cloud-storage/types'
import { getGenerateUrl } from './generateURL.js'
import { getHandleDelete } from './handleDelete.js'
import { getHandleUpload } from './handleUpload.js'
import { getStaticHandler } from './staticHandler.js'
export interface VercelBlobAdapterArgs {
options?: VercelBlobAdapterUploadOptions
/**
* Vercel Blob storage read/write token
*
* Usually process.env.BLOB_READ_WRITE_TOKEN set by Vercel
*/
token: string
}
export interface VercelBlobAdapterUploadOptions {
/**
* Access control level
*
* @default 'public'
*/
access?: 'public'
/**
* Add a random suffix to the uploaded file name
*
* @default false
*/
addRandomSuffix?: boolean
/**
* Cache-Control max-age in seconds
*
* @default 31536000 (1 year)
*/
cacheControlMaxAge?: number
}
const defaultUploadOptions: VercelBlobAdapterUploadOptions = {
access: 'public',
addRandomSuffix: false,
cacheControlMaxAge: 60 * 60 * 24 * 365, // 1 year
}
export const vercelBlobAdapter =
({ options = {}, token }: VercelBlobAdapterArgs): Adapter =>
({ collection, prefix }): GeneratedAdapter => {
if (!token) {
throw new Error('The token argument is required for the Vercel Blob adapter.')
}
// Parse storeId from token
const storeId = token.match(/^vercel_blob_rw_([a-z\d]+)_[a-z\d]+$/i)?.[1].toLowerCase()
if (!storeId) {
throw new Error(
'Invalid token format for Vercel Blob adapter. Should be vercel_blob_rw_<store_id>_<random_string>.',
)
}
const { access, addRandomSuffix, cacheControlMaxAge } = {
...defaultUploadOptions,
...options,
}
const baseUrl = `https://${storeId}.${access}.blob.vercel-storage.com`
return {
generateURL: getGenerateUrl({ baseUrl, prefix }),
handleDelete: getHandleDelete({ baseUrl, prefix, token }),
handleUpload: getHandleUpload({
access,
addRandomSuffix,
baseUrl,
cacheControlMaxAge,
prefix,
token,
}),
staticHandler: getStaticHandler({ baseUrl, token }, collection),
}
}

View File

@@ -0,0 +1,81 @@
import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types'
import type { CollectionConfig, PayloadRequest, UploadConfig } from 'payload/types'
import { head } from '@vercel/blob'
import path from 'path'
type StaticHandlerArgs = {
baseUrl: string
token: string
}
export const getStaticHandler = (
{ baseUrl, token }: StaticHandlerArgs,
collection: CollectionConfig,
): StaticHandler => {
return async (req, { params: { filename } }) => {
try {
const prefix = await getFilePrefix({ collection, req })
const fileUrl = `${baseUrl}/${path.posix.join(prefix, filename)}`
const blobMetadata = await head(fileUrl, { token })
if (!blobMetadata) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
const { contentDisposition, contentType, size } = blobMetadata
const response = await fetch(fileUrl)
const blob = await response.blob()
if (!blob) {
return new Response(null, { status: 204, statusText: 'No Content' })
}
const bodyBuffer = await blob.arrayBuffer()
return new Response(bodyBuffer, {
headers: new Headers({
'Content-Disposition': contentDisposition,
'Content-Length': String(size),
'Content-Type': contentType,
}),
status: 200,
})
} catch (err: unknown) {
req.payload.logger.error({ err, msg: 'Unexpected error in staticHandler' })
return new Response('Internal Server Error', { status: 500 })
}
}
}
async function getFilePrefix({
collection,
req,
}: {
collection: CollectionConfig
req: PayloadRequest
}): Promise<string> {
const imageSizes = (collection?.upload as UploadConfig)?.imageSizes || []
const { routeParams } = req
const filename = routeParams?.['filename']
const files = await req.payload.find({
collection: collection.slug,
depth: 0,
limit: 1,
pagination: false,
where: {
or: [
{
filename: { equals: filename },
},
...imageSizes.map((imageSize) => ({
[`sizes.${imageSize.name}.filename`]: { equals: filename },
})),
],
},
})
const prefix = files?.docs?.[0]?.prefix
return prefix ? (prefix as string) : ''
}

31
pnpm-lock.yaml generated
View File

@@ -982,6 +982,9 @@ importers:
'@types/find-node-modules':
specifier: ^2.1.2
version: 2.1.2
'@vercel/blob':
specifier: ^0.22.3
version: 0.22.3
payload:
specifier: workspace:*
version: link:../payload
@@ -3743,6 +3746,11 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@fastify/busboy@2.1.1:
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
dev: true
/@floating-ui/core@1.6.0:
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
dependencies:
@@ -6512,6 +6520,16 @@ packages:
/@ungap/structured-clone@1.2.0:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
/@vercel/blob@0.22.3:
resolution: {integrity: sha512-l0t2KhbOO/I8ZNOl9zypYf1NE0837aO4/CPQNGR/RAxtj8FpdYKjhyUADUXj2gERLQmnhun+teaVs/G7vZJ/TQ==}
engines: {node: '>=16.14'}
dependencies:
async-retry: 1.3.3
bytes: 3.1.2
is-buffer: 2.0.5
undici: 5.28.4
dev: true
/@webassemblyjs/ast@1.12.1:
resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==}
dependencies:
@@ -7385,7 +7403,6 @@ packages:
/bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
dev: false
/cacheable-lookup@5.0.4:
resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==}
@@ -10648,6 +10665,11 @@ packages:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
dev: false
/is-buffer@2.0.5:
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
engines: {node: '>=4'}
dev: true
/is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
@@ -15951,6 +15973,13 @@ packages:
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
/undici@5.28.4:
resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==}
engines: {node: '>=14.0'}
dependencies:
'@fastify/busboy': 2.1.1
dev: true
/unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
dev: false