feat(plugin-cloud-storage): implement storage packages (#5928)
This commit is contained in:
10
packages/storage-vercel-blob/.eslintignore
Normal file
10
packages/storage-vercel-blob/.eslintignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
7
packages/storage-vercel-blob/.eslintrc.cjs
Normal file
7
packages/storage-vercel-blob/.eslintrc.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
10
packages/storage-vercel-blob/.prettierignore
Normal file
10
packages/storage-vercel-blob/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
15
packages/storage-vercel-blob/.swcrc
Normal file
15
packages/storage-vercel-blob/.swcrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
22
packages/storage-vercel-blob/LICENSE.md
Normal file
22
packages/storage-vercel-blob/LICENSE.md
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
|
||||
Portions Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1
packages/storage-vercel-blob/README.md
Normal file
1
packages/storage-vercel-blob/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Vercel Blob Storage
|
||||
58
packages/storage-vercel-blob/package.json
Normal file
58
packages/storage-vercel-blob/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@payloadcms/storage-vercel-blob",
|
||||
"version": "0.0.0",
|
||||
"description": "Payload storage adapter for Vercel Blob Storage",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/payloadcms/payload.git",
|
||||
"directory": "packages/storage-vercel-blob"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"author": "Payload CMS, Inc.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm build:swc && pnpm build:types",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"prepublishOnly": "pnpm clean && pnpm turbo build"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/plugin-cloud-storage": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vercel/blob": "^0.22.3",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
14
packages/storage-vercel-blob/src/generateURL.ts
Normal file
14
packages/storage-vercel-blob/src/generateURL.ts
Normal 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)}`
|
||||
}
|
||||
}
|
||||
19
packages/storage-vercel-blob/src/handleDelete.ts
Normal file
19
packages/storage-vercel-blob/src/handleDelete.ts
Normal 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
|
||||
}
|
||||
}
|
||||
39
packages/storage-vercel-blob/src/handleUpload.ts
Normal file
39
packages/storage-vercel-blob/src/handleUpload.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { HandleUpload } from '@payloadcms/plugin-cloud-storage/types'
|
||||
|
||||
import { put } from '@vercel/blob'
|
||||
import path from 'path'
|
||||
|
||||
import type { VercelBlobStorageOptions } from './index.js'
|
||||
|
||||
type HandleUploadArgs = Omit<VercelBlobStorageOptions, 'collections'> & {
|
||||
baseUrl: string
|
||||
prefix?: 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
|
||||
}
|
||||
}
|
||||
133
packages/storage-vercel-blob/src/index.ts
Normal file
133
packages/storage-vercel-blob/src/index.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
PluginOptions as CloudStoragePluginOptions,
|
||||
CollectionOptions,
|
||||
} from '@payloadcms/plugin-cloud-storage/types'
|
||||
import type { Adapter, GeneratedAdapter } from '@payloadcms/plugin-cloud-storage/types'
|
||||
import type { Config, Plugin } from 'payload/config'
|
||||
|
||||
import { cloudStorage } from '@payloadcms/plugin-cloud-storage'
|
||||
|
||||
import { getGenerateUrl } from './generateURL.js'
|
||||
import { getHandleDelete } from './handleDelete.js'
|
||||
import { getHandleUpload } from './handleUpload.js'
|
||||
import { getStaticHandler } from './staticHandler.js'
|
||||
|
||||
export type VercelBlobStorageOptions = {
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Collections to apply the Vercel Blob adapter to
|
||||
*/
|
||||
collections: Record<string, Omit<CollectionOptions, 'adapter'> | true>
|
||||
|
||||
/**
|
||||
* Whether or not to enable the plugin
|
||||
*
|
||||
* Default: true
|
||||
*/
|
||||
enabled?: boolean
|
||||
|
||||
/**
|
||||
* Vercel Blob storage read/write token
|
||||
*
|
||||
* Usually process.env.BLOB_READ_WRITE_TOKEN set by Vercel
|
||||
*/
|
||||
token: string
|
||||
}
|
||||
|
||||
const defaultUploadOptions: Partial<VercelBlobStorageOptions> = {
|
||||
access: 'public',
|
||||
addRandomSuffix: false,
|
||||
cacheControlMaxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
type VercelBlobStoragePlugin = (vercelBlobStorageOpts: VercelBlobStorageOptions) => Plugin
|
||||
|
||||
export const vercelBlobStorage: VercelBlobStoragePlugin =
|
||||
(options: VercelBlobStorageOptions) =>
|
||||
(incomingConfig: Config): Config => {
|
||||
if (options.enabled === false) {
|
||||
return incomingConfig
|
||||
}
|
||||
|
||||
if (!options.token) {
|
||||
throw new Error('The token argument is required for the Vercel Blob adapter.')
|
||||
}
|
||||
|
||||
// Parse storeId from token
|
||||
const storeId = options.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 optionsWithDefaults = {
|
||||
...defaultUploadOptions,
|
||||
...options,
|
||||
}
|
||||
|
||||
const baseUrl = `https://${storeId}.${optionsWithDefaults.access}.blob.vercel-storage.com`
|
||||
|
||||
const adapter = vercelBlobStorageInternal({ ...optionsWithDefaults, baseUrl })
|
||||
|
||||
// Add adapter to each collection option object
|
||||
const collectionsWithAdapter: CloudStoragePluginOptions['collections'] = Object.entries(
|
||||
options.collections,
|
||||
).reduce(
|
||||
(acc, [slug, collOptions]) => ({
|
||||
...acc,
|
||||
[slug]: {
|
||||
...(collOptions === true ? {} : collOptions),
|
||||
adapter,
|
||||
},
|
||||
}),
|
||||
{} as Record<string, CollectionOptions>,
|
||||
)
|
||||
|
||||
return cloudStorage({
|
||||
collections: collectionsWithAdapter,
|
||||
})(incomingConfig)
|
||||
}
|
||||
|
||||
function vercelBlobStorageInternal(
|
||||
options: VercelBlobStorageOptions & { baseUrl: string },
|
||||
): Adapter {
|
||||
return ({ collection, prefix }): GeneratedAdapter => {
|
||||
const { access, addRandomSuffix, baseUrl, cacheControlMaxAge, token } = options
|
||||
return {
|
||||
generateURL: getGenerateUrl({ baseUrl, prefix }),
|
||||
handleDelete: getHandleDelete({ baseUrl, prefix, token: options.token }),
|
||||
handleUpload: getHandleUpload({
|
||||
access,
|
||||
addRandomSuffix,
|
||||
baseUrl,
|
||||
cacheControlMaxAge,
|
||||
prefix,
|
||||
token,
|
||||
}),
|
||||
staticHandler: getStaticHandler({ baseUrl, token }, collection),
|
||||
}
|
||||
}
|
||||
}
|
||||
51
packages/storage-vercel-blob/src/staticHandler.ts
Normal file
51
packages/storage-vercel-blob/src/staticHandler.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types'
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
import { getFilePrefix } from '@payloadcms/plugin-cloud-storage/utilities'
|
||||
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, filename, 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/storage-vercel-blob/tsconfig.json
Normal file
20
packages/storage-vercel-blob/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true, // Make sure typescript knows that this module depends on their references
|
||||
"noEmit": false /* Do not emit outputs. */,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
||||
"rootDir": "./src" /* Specify the root folder within your source files. */,
|
||||
"strict": true,
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
|
||||
"references": [
|
||||
{ "path": "../payload" },
|
||||
{ "path": "../plugin-cloud-storage" },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user