diff --git a/.changeset/great-fans-play.md b/.changeset/great-fans-play.md new file mode 100644 index 000000000..82ca7d4c7 --- /dev/null +++ b/.changeset/great-fans-play.md @@ -0,0 +1,16 @@ +--- +"@vercel/blob": patch +"vercel-storage-integration-test-suite": patch +--- + +feat(blob): allow folder creation + +This allows the creation of empty folders in the blob store. Before this change the SDK would always require a body, which is prohibited by the API. +Now the the SDK validates if the operation is a folder creation by checking if the pathname ends with a trailling slash. + +```ts +const blob = await vercelBlob.put('folder/', { + access: 'public', + addRandomSuffix: false, +}); +``` diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index bd78e6b22..1978d426c 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -22,9 +22,9 @@ export type { PutBlobResult, PutCommandOptions } from './put'; * * If you want to upload from the browser directly, check out the documentation for client uploads: https://vercel.com/docs/storage/vercel-blob/using-blob-sdk#client-uploads * - * @param pathname - The pathname to upload the blob to. This includes the filename. - * @param body - The contents of your blob. This has to be a supported fetch body type https://developer.mozilla.org/en-US/docs/Web/API/fetch#body. - * @param options - Additional options like `token` or `contentType`. + * @param pathname - The pathname to upload the blob to. For file upload this includes the filename. Pathnames that end with a slash are treated as folder creations. + * @param bodyOrOptions - Either the contents of your blob or the options object. For file uploads this has to be a supported fetch body type https://developer.mozilla.org/en-US/docs/Web/API/fetch#body. For folder creations this is the options object since no body is required. + * @param options - Additional options like `token` or `contentType` for file uploads. For folder creations this argument can be ommited. */ export const put = createPutMethod({ allowedOptions: ['cacheControlMaxAge', 'addRandomSuffix', 'contentType'], diff --git a/packages/blob/src/put.ts b/packages/blob/src/put.ts index 1264421cf..19fcebe30 100644 --- a/packages/blob/src/put.ts +++ b/packages/blob/src/put.ts @@ -42,26 +42,42 @@ export function createPutMethod< getToken?: (pathname: string, options: T) => Promise; extraChecks?: (options: T) => void; }) { - return async function put( - pathname: string, - body: - | string - | Readable - | Blob - | ArrayBuffer - | FormData - | ReadableStream - | File, - options?: T, + return async function put( + pathname: TPath, + bodyOrOptions: TPath extends `${string}/` + ? T + : + | string + | Readable + | Blob + | ArrayBuffer + | FormData + | ReadableStream + | File, + optionsInput?: T, ): Promise { if (!pathname) { throw new BlobError('pathname is required'); } - if (!body) { + const isFolderCreation = pathname.endsWith('/'); + + // prevent empty bodies for files + if (!bodyOrOptions && !isFolderCreation) { throw new BlobError('body is required'); } + // runtime check for non TS users that provide all three args + if (bodyOrOptions && optionsInput && isFolderCreation) { + throw new BlobError('body is not allowed for creating empty folders'); + } + + // avoid using the options as body + const body = isFolderCreation ? undefined : (bodyOrOptions as BodyInit); + + // when no body is required options are the second argument + const options = isFolderCreation ? (bodyOrOptions as T) : optionsInput; + if (!options) { throw new BlobError('missing options, see usage'); } @@ -105,7 +121,7 @@ export function createPutMethod< const blobApiResponse = await fetch(getApiUrl(`/${pathname}`), { method: 'PUT', - body: body as BodyInit, + body, headers, // required in order to stream some body types to Cloudflare // currently only supported in Node.js, we may have to feature detect this diff --git a/test/next/src/app/vercel/blob/script.mts b/test/next/src/app/vercel/blob/script.mts index b662c257a..c71c98917 100644 --- a/test/next/src/app/vercel/blob/script.mts +++ b/test/next/src/app/vercel/blob/script.mts @@ -32,6 +32,7 @@ async function run(): Promise { weirdCharactersExample(), copyTextFile(), listFolders(), + createFolder(), ]); await Promise.all( @@ -291,3 +292,16 @@ async function listFolders() { return blob.url; } + +async function createFolder() { + const start = Date.now(); + + const blob = await vercelBlob.put('foolder/', { + access: 'public', + addRandomSuffix: false, + }); + + console.log('create folder example:', blob, `(${Date.now() - start}ms)`); + + return blob.url; +}