From 100ca39f3d1e10ee22463377207e3505ae0c42c1 Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Wed, 29 Jul 2026 17:47:05 -0300 Subject: [PATCH 1/2] [blob] Add optimizeImage option to put and new putFromUrl method Both call the new blob API endpoints (POST /put-optimized and POST /put-from-url) that optimize the image through Vercel Image Optimization before storing it. OIDC-only. Co-Authored-By: Claude Fable 5 --- .changeset/blob-optimize-image.md | 17 +++++ packages/blob/src/index.ts | 10 +++ packages/blob/src/put-from-url.ts | 115 ++++++++++++++++++++++++++++++ packages/blob/src/put-helpers.ts | 44 ++++++++++++ packages/blob/src/put.ts | 44 +++++++++++- 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 .changeset/blob-optimize-image.md create mode 100644 packages/blob/src/put-from-url.ts diff --git a/.changeset/blob-optimize-image.md b/.changeset/blob-optimize-image.md new file mode 100644 index 000000000..040d15fa3 --- /dev/null +++ b/.changeset/blob-optimize-image.md @@ -0,0 +1,17 @@ +--- +'@vercel/blob': minor +--- + +Add image optimization support: a new `optimizeImage` option on `put` and a new `putFromUrl` method. Both optimize the image through Vercel Image Optimization before storing it (only the optimized output is stored) and require OIDC authentication. + +```ts +const result = await put('avatars/foo.webp', body, { + access: 'public', + optimizeImage: { width: 128, quality: 75, format: 'webp' }, +}); + +const result = await putFromUrl('avatars/foo.webp', 'https://example.com/photo.jpg', { + access: 'public', + optimizeImage: { width: 128, quality: 75, format: 'webp' }, +}); +``` diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index ce1af7e73..bffce0663 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -59,6 +59,7 @@ export type { PutCommandOptions }; * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - multipart - (Optional) Whether to use multipart upload for large files. It will split the file into multiple parts, upload them in parallel and retry failed parts. + * - optimizeImage - (Optional) Optimize the image through Vercel Image Optimization before storing it (\{width: number, quality?: number, format?: 'jpeg' | 'png' | 'webp' | 'avif'\}). Only the optimized output is stored. Requires OIDC authentication and cannot be combined with multipart. Billed as an image transformation plus a regular blob put. * - abortSignal - (Optional) AbortSignal to cancel the operation. * - onUploadProgress - (Optional) Callback to track upload progress: onUploadProgress(\{loaded: number, total: number, percentage: number\}) * @returns A promise that resolves to the blob information, including pathname, contentType, contentDisposition, url, and downloadUrl. @@ -108,6 +109,15 @@ export { copy } from './copy'; export type { RenameBlobResult, RenameCommandOptions } from './rename'; export { rename } from './rename'; +// vercelBlob.putFromUrl() + +export type { + PutFromUrlBlobResult, + PutFromUrlCommandOptions, +} from './put-from-url'; +export { putFromUrl } from './put-from-url'; +export type { OptimizeImageOptions } from './put-helpers'; + // vercelBlob. createMultipartUpload() // vercelBlob. uploadPart() // vercelBlob. completeMultipartUpload() diff --git a/packages/blob/src/put-from-url.ts b/packages/blob/src/put-from-url.ts new file mode 100644 index 000000000..ef273e0f3 --- /dev/null +++ b/packages/blob/src/put-from-url.ts @@ -0,0 +1,115 @@ +import { MAXIMUM_PATHNAME_LENGTH, requestApi } from './api'; +import type { CommonCreateBlobOptions } from './helpers'; +import { BlobError, disallowedPathnameCharacters } from './helpers'; +import type { OptimizeImageOptions, PutBlobResult } from './put-helpers'; +import { addOptimizeImageParams } from './put-helpers'; + +export interface PutFromUrlCommandOptions extends CommonCreateBlobOptions { + /** + * Optimize the image through Vercel Image Optimization before storing it. + * Only the optimized output is stored. + */ + optimizeImage: OptimizeImageOptions; +} + +export type PutFromUrlBlobResult = PutBlobResult; + +/** + * Fetches an image from a public URL, optimizes it through Vercel Image + * Optimization, and stores the optimized output in your store. The source + * image is fetched server-side; only the optimized result is stored. + * Requires OIDC authentication. Billed as an image transformation plus a + * regular blob put. + * + * @param pathname - The pathname to store the optimized image at, including the extension. + * @param url - The public http(s) URL of the source image. + * @param options - Configuration options including: + * - access - (Required) Must be 'public' or 'private'. + * - optimizeImage - (Required) Image optimization parameters (\{width: number, quality?: number, format?: 'jpeg' | 'png' | 'webp' | 'avif'\}). + * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. It defaults to false. + * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if the destination blob already exists. + * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. + * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. + * - abortSignal - (Optional) AbortSignal to cancel the operation. + * @returns A promise that resolves to the stored blob information, including pathname, contentType, contentDisposition, url, and downloadUrl. + */ +export async function putFromUrl( + pathname: string, + url: string, + options: PutFromUrlCommandOptions, +): Promise { + if (!options) { + throw new BlobError('missing options, see usage'); + } + + if (options.access !== 'public' && options.access !== 'private') { + throw new BlobError( + 'access must be "private" or "public", see https://vercel.com/docs/vercel-blob', + ); + } + + if (!pathname) { + throw new BlobError('pathname is required'); + } + + if (pathname.length > MAXIMUM_PATHNAME_LENGTH) { + throw new BlobError( + `pathname is too long, maximum length is ${MAXIMUM_PATHNAME_LENGTH}`, + ); + } + + for (const invalidCharacter of disallowedPathnameCharacters) { + if (pathname.includes(invalidCharacter)) { + throw new BlobError( + `pathname cannot contain "${invalidCharacter}", please encode it if needed`, + ); + } + } + + if (!url) { + throw new BlobError('url is required'); + } + + if (!options.optimizeImage) { + throw new BlobError('optimizeImage is required, see usage'); + } + + const headers: Record = { + 'x-vercel-blob-access': options.access, + }; + + if (options.addRandomSuffix !== undefined) { + headers['x-add-random-suffix'] = options.addRandomSuffix ? '1' : '0'; + } + + if (options.allowOverwrite !== undefined) { + headers['x-allow-overwrite'] = options.allowOverwrite ? '1' : '0'; + } + + if (options.cacheControlMaxAge !== undefined) { + headers['x-cache-control-max-age'] = options.cacheControlMaxAge.toString(); + } + + const params = new URLSearchParams({ pathname, url }); + addOptimizeImageParams(params, options.optimizeImage); + + const response = await requestApi( + `/put-from-url?${params.toString()}`, + { + method: 'POST', + headers, + signal: options.abortSignal, + }, + options, + ); + + return { + url: response.url, + downloadUrl: response.downloadUrl, + pathname: response.pathname, + contentType: response.contentType, + contentDisposition: response.contentDisposition, + etag: response.etag, + }; +} diff --git a/packages/blob/src/put-helpers.ts b/packages/blob/src/put-helpers.ts index a1423bdf1..576924b22 100644 --- a/packages/blob/src/put-helpers.ts +++ b/packages/blob/src/put-helpers.ts @@ -8,6 +8,50 @@ import type { ClientCommonCreateBlobOptions } from './client'; import type { CommonCreateBlobOptions, PresignedUrlPayload } from './helpers'; import { BlobError, disallowedPathnameCharacters } from './helpers'; +/** + * Options to optimize an image through Vercel Image Optimization before + * storing it. Requires OIDC authentication. + */ +export interface OptimizeImageOptions { + /** + * The desired width of the optimized image in pixels (1-3840). + */ + width: number; + /** + * The desired quality of the optimized image (1-100). + * @defaultvalue 75 + */ + quality?: number; + /** + * The desired output format. The original format is preserved when omitted. + */ + format?: 'jpeg' | 'png' | 'webp' | 'avif'; +} + +const optimizeImageFormatToMimeType = { + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + avif: 'image/avif', +} as const; + +// Query params for the put-optimized/put-from-url endpoints; parameter +// validation itself is owned by the API. +export function addOptimizeImageParams( + params: URLSearchParams, + optimizeImage: OptimizeImageOptions, +): void { + params.set('width', String(optimizeImage.width)); + params.set('quality', String(optimizeImage.quality ?? 75)); + if (optimizeImage.format) { + params.set( + 'format', + optimizeImageFormatToMimeType[optimizeImage.format] ?? + optimizeImage.format, + ); + } +} + export const putOptionHeaderMap = { cacheControlMaxAge: 'x-cache-control-max-age', addRandomSuffix: 'x-add-random-suffix', diff --git a/packages/blob/src/put.ts b/packages/blob/src/put.ts index a29f895b0..ba91831e2 100644 --- a/packages/blob/src/put.ts +++ b/packages/blob/src/put.ts @@ -5,11 +5,16 @@ import { BlobError, isPlainObject } from './helpers'; import { uncontrolledMultipartUpload } from './multipart/uncontrolled'; import type { CreatePutMethodOptions, + OptimizeImageOptions, PutBlobApiResponse, PutBlobResult, PutBody, } from './put-helpers'; -import { createPutHeaders, createPutOptions } from './put-helpers'; +import { + addOptimizeImageParams, + createPutHeaders, + createPutOptions, +} from './put-helpers'; export interface PutCommandOptions extends CommonCreateBlobOptions, @@ -19,6 +24,12 @@ export interface PutCommandOptions * @defaultvalue false */ multipart?: boolean; + /** + * Optimize the image through Vercel Image Optimization before storing it. + * Only the optimized output is stored. Requires OIDC authentication and is + * billed as an image transformation plus a regular blob put. + */ + optimizeImage?: OptimizeImageOptions; } export function createPutMethod({ @@ -61,6 +72,37 @@ export function createPutMethod({ const headers = createPutHeaders(allowedOptions, options); + if (options.optimizeImage) { + if (options.multipart === true) { + throw new BlobError( + 'optimizeImage cannot be combined with multipart uploads', + ); + } + + const params = new URLSearchParams({ pathname }); + addOptimizeImageParams(params, options.optimizeImage); + + const response = await requestApi( + `/put-optimized?${params.toString()}`, + { + method: 'POST', + body, + headers, + signal: options.abortSignal, + }, + optionsWithPresignedUrlPayload, + ); + + return { + url: response.url, + downloadUrl: response.downloadUrl, + pathname: response.pathname, + contentType: response.contentType, + contentDisposition: response.contentDisposition, + etag: response.etag, + }; + } + if (options.multipart === true) { return uncontrolledMultipartUpload( pathname, From b545cca4b90e9b2d66fd7b9788047981e36fb06f Mon Sep 17 00:00:00 2001 From: Agustin Falco Date: Thu, 30 Jul 2026 12:13:21 -0300 Subject: [PATCH 2/2] [blob] Fix putFromUrl dropping ifMatch and reuse shared put validation putFromUrl now goes through createPutOptions/createPutHeaders like every other put-style method, which forwards x-if-match (with the implicit allowOverwrite behavior) instead of silently dropping it. contentType is omitted from PutFromUrlCommandOptions since the stored content type always comes from the optimizer output. Adds tests for put({optimizeImage}) and putFromUrl. Co-Authored-By: Claude Fable 5 --- packages/blob/src/put-from-url.node.test.ts | 248 ++++++++++++++++++++ packages/blob/src/put-from-url.ts | 81 +++---- 2 files changed, 276 insertions(+), 53 deletions(-) create mode 100644 packages/blob/src/put-from-url.node.test.ts diff --git a/packages/blob/src/put-from-url.node.test.ts b/packages/blob/src/put-from-url.node.test.ts new file mode 100644 index 000000000..65d8cb96d --- /dev/null +++ b/packages/blob/src/put-from-url.node.test.ts @@ -0,0 +1,248 @@ +import { type Interceptable, MockAgent, setGlobalDispatcher } from 'undici'; +import { put, putFromUrl } from './index'; + +const BLOB_API_URL_AGENT = 'https://vercel.com'; +const BLOB_STORE_BASE_URL = 'https://storeId.public.blob.vercel-storage.com'; + +const mockedOptimizedBlob = { + url: `${BLOB_STORE_BASE_URL}/avatar-id.webp`, + downloadUrl: `${BLOB_STORE_BASE_URL}/avatar-id.webp?download=1`, + pathname: 'avatar.webp', + contentType: 'image/webp', + contentDisposition: 'inline; filename="avatar.webp"', + etag: '"abc123"', +}; + +describe('optimizeImage', () => { + let mockClient: Interceptable; + + beforeEach(() => { + delete process.env.BLOB_STORE_ID; + delete process.env.VERCEL_OIDC_TOKEN; + process.env.BLOB_READ_WRITE_TOKEN = + 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; + const mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + mockClient = mockAgent.get(BLOB_API_URL_AGENT); + jest.resetAllMocks(); + + process.env.VERCEL_BLOB_RETRIES = '0'; + }); + + describe('put with optimizeImage', () => { + it('sends a POST to /put-optimized with the optimize params and body', async () => { + let path: string | null = null; + let headers: Record = {}; + let body = ''; + mockClient + .intercept({ + path: () => true, + method: 'POST', + }) + .reply(200, (req) => { + path = req.path; + headers = req.headers as Record; + body = req.body as string; + return mockedOptimizedBlob; + }); + + await expect( + put('avatar.webp', 'image-bytes', { + access: 'public', + optimizeImage: { width: 128, quality: 80, format: 'webp' }, + }), + ).resolves.toEqual(mockedOptimizedBlob); + + expect(path).toBe( + '/api/blob/put-optimized?pathname=avatar.webp&width=128&quality=80&format=image%2Fwebp', + ); + expect(headers['x-vercel-blob-access']).toBe('public'); + expect(body).toBe('image-bytes'); + }); + + it('defaults quality to 75 and omits format when not provided', async () => { + let path: string | null = null; + mockClient + .intercept({ + path: () => true, + method: 'POST', + }) + .reply(200, (req) => { + path = req.path; + return mockedOptimizedBlob; + }); + + await put('avatar.webp', 'image-bytes', { + access: 'public', + optimizeImage: { width: 128 }, + }); + + expect(path).toBe( + '/api/blob/put-optimized?pathname=avatar.webp&width=128&quality=75', + ); + }); + + it('forwards put headers like addRandomSuffix and cacheControlMaxAge', async () => { + let headers: Record = {}; + mockClient + .intercept({ + path: () => true, + method: 'POST', + }) + .reply(200, (req) => { + headers = req.headers as Record; + return mockedOptimizedBlob; + }); + + await put('avatar.webp', 'image-bytes', { + access: 'public', + addRandomSuffix: true, + cacheControlMaxAge: 60, + optimizeImage: { width: 128 }, + }); + + expect(headers['x-add-random-suffix']).toBe('1'); + expect(headers['x-cache-control-max-age']).toBe('60'); + }); + + it('throws when combined with multipart', async () => { + await expect( + put('avatar.webp', 'image-bytes', { + access: 'public', + multipart: true, + optimizeImage: { width: 128 }, + }), + ).rejects.toThrow( + 'Vercel Blob: optimizeImage cannot be combined with multipart uploads', + ); + }); + }); + + describe('putFromUrl', () => { + it('sends a POST to /put-from-url with the source url and optimize params', async () => { + let path: string | null = null; + let headers: Record = {}; + mockClient + .intercept({ + path: () => true, + method: 'POST', + }) + .reply(200, (req) => { + path = req.path; + headers = req.headers as Record; + return mockedOptimizedBlob; + }); + + await expect( + putFromUrl('avatar.webp', 'https://example.com/photo.jpg', { + access: 'public', + optimizeImage: { width: 128, quality: 80, format: 'webp' }, + }), + ).resolves.toEqual(mockedOptimizedBlob); + + expect(path).toBe( + '/api/blob/put-from-url?pathname=avatar.webp&url=https%3A%2F%2Fexample.com%2Fphoto.jpg&width=128&quality=80&format=image%2Fwebp', + ); + expect(headers['x-vercel-blob-access']).toBe('public'); + }); + + it('forwards addRandomSuffix, allowOverwrite and cacheControlMaxAge headers', async () => { + let headers: Record = {}; + mockClient + .intercept({ + path: () => true, + method: 'POST', + }) + .reply(200, (req) => { + headers = req.headers as Record; + return mockedOptimizedBlob; + }); + + await putFromUrl('avatar.webp', 'https://example.com/photo.jpg', { + access: 'public', + addRandomSuffix: true, + allowOverwrite: true, + cacheControlMaxAge: 60, + optimizeImage: { width: 128 }, + }); + + expect(headers['x-add-random-suffix']).toBe('1'); + expect(headers['x-allow-overwrite']).toBe('1'); + expect(headers['x-cache-control-max-age']).toBe('60'); + }); + + it('sends x-if-match and implicitly allows overwrite when ifMatch is provided', async () => { + let headers: Record = {}; + mockClient + .intercept({ + path: () => true, + method: 'POST', + }) + .reply(200, (req) => { + headers = req.headers as Record; + return mockedOptimizedBlob; + }); + + await putFromUrl('avatar.webp', 'https://example.com/photo.jpg', { + access: 'public', + ifMatch: '"abc123"', + optimizeImage: { width: 128 }, + }); + + expect(headers['x-if-match']).toBe('"abc123"'); + expect(headers['x-allow-overwrite']).toBe('1'); + }); + + it('throws when ifMatch is combined with allowOverwrite: false', async () => { + await expect( + putFromUrl('avatar.webp', 'https://example.com/photo.jpg', { + access: 'public', + ifMatch: '"abc123"', + allowOverwrite: false, + optimizeImage: { width: 128 }, + }), + ).rejects.toThrow( + 'Vercel Blob: ifMatch and allowOverwrite: false are contradictory.', + ); + }); + + it('throws when pathname is missing', async () => { + await expect( + putFromUrl('', 'https://example.com/photo.jpg', { + access: 'public', + optimizeImage: { width: 128 }, + }), + ).rejects.toThrow('Vercel Blob: pathname is required'); + }); + + it('throws when access is invalid', async () => { + await expect( + putFromUrl('avatar.webp', 'https://example.com/photo.jpg', { + // @ts-expect-error -- testing runtime validation + access: 'protected', + optimizeImage: { width: 128 }, + }), + ).rejects.toThrow('Vercel Blob: access must be "private" or "public"'); + }); + + it('throws when url is missing', async () => { + await expect( + putFromUrl('avatar.webp', '', { + access: 'public', + optimizeImage: { width: 128 }, + }), + ).rejects.toThrow('Vercel Blob: url is required'); + }); + + it('throws when optimizeImage is missing', async () => { + await expect( + putFromUrl('avatar.webp', 'https://example.com/photo.jpg', { + access: 'public', + // @ts-expect-error -- testing runtime validation + optimizeImage: undefined, + }), + ).rejects.toThrow('Vercel Blob: optimizeImage is required, see usage'); + }); + }); +}); diff --git a/packages/blob/src/put-from-url.ts b/packages/blob/src/put-from-url.ts index ef273e0f3..a2b6a71b7 100644 --- a/packages/blob/src/put-from-url.ts +++ b/packages/blob/src/put-from-url.ts @@ -1,10 +1,21 @@ -import { MAXIMUM_PATHNAME_LENGTH, requestApi } from './api'; +import { requestApi } from './api'; import type { CommonCreateBlobOptions } from './helpers'; -import { BlobError, disallowedPathnameCharacters } from './helpers'; -import type { OptimizeImageOptions, PutBlobResult } from './put-helpers'; -import { addOptimizeImageParams } from './put-helpers'; - -export interface PutFromUrlCommandOptions extends CommonCreateBlobOptions { +import { BlobError } from './helpers'; +import type { + OptimizeImageOptions, + PutBlobApiResponse, + PutBlobResult, +} from './put-helpers'; +import { + addOptimizeImageParams, + createPutHeaders, + createPutOptions, +} from './put-helpers'; + +// `contentType` is omitted because the stored content type always comes from +// the optimizer output, not from the caller. +export interface PutFromUrlCommandOptions + extends Omit { /** * Optimize the image through Vercel Image Optimization before storing it. * Only the optimized output is stored. @@ -29,6 +40,7 @@ export type PutFromUrlBlobResult = PutBlobResult; * - addRandomSuffix - (Optional) A boolean specifying whether to add a random suffix to the pathname. It defaults to false. * - allowOverwrite - (Optional) A boolean to allow overwriting blobs. By default an error will be thrown if the destination blob already exists. * - cacheControlMaxAge - (Optional) A number in seconds to configure how long Blobs are cached. + * - ifMatch - (Optional) Only perform the operation if the blob's current ETag matches this value. Implies allowOverwrite. * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. * - storeId - (Optional) Blob store id. Used to override process.env.BLOB_STORE_ID when Vercel OIDC token is available. * - abortSignal - (Optional) AbortSignal to cancel the operation. @@ -39,69 +51,32 @@ export async function putFromUrl( url: string, options: PutFromUrlCommandOptions, ): Promise { - if (!options) { - throw new BlobError('missing options, see usage'); - } - - if (options.access !== 'public' && options.access !== 'private') { - throw new BlobError( - 'access must be "private" or "public", see https://vercel.com/docs/vercel-blob', - ); - } - - if (!pathname) { - throw new BlobError('pathname is required'); - } - - if (pathname.length > MAXIMUM_PATHNAME_LENGTH) { - throw new BlobError( - `pathname is too long, maximum length is ${MAXIMUM_PATHNAME_LENGTH}`, - ); - } - - for (const invalidCharacter of disallowedPathnameCharacters) { - if (pathname.includes(invalidCharacter)) { - throw new BlobError( - `pathname cannot contain "${invalidCharacter}", please encode it if needed`, - ); - } - } + const putOptions = await createPutOptions({ pathname, options }); if (!url) { throw new BlobError('url is required'); } - if (!options.optimizeImage) { + if (!putOptions.optimizeImage) { throw new BlobError('optimizeImage is required, see usage'); } - const headers: Record = { - 'x-vercel-blob-access': options.access, - }; - - if (options.addRandomSuffix !== undefined) { - headers['x-add-random-suffix'] = options.addRandomSuffix ? '1' : '0'; - } - - if (options.allowOverwrite !== undefined) { - headers['x-allow-overwrite'] = options.allowOverwrite ? '1' : '0'; - } - - if (options.cacheControlMaxAge !== undefined) { - headers['x-cache-control-max-age'] = options.cacheControlMaxAge.toString(); - } + const headers = createPutHeaders( + ['cacheControlMaxAge', 'addRandomSuffix', 'allowOverwrite', 'ifMatch'], + putOptions, + ); const params = new URLSearchParams({ pathname, url }); - addOptimizeImageParams(params, options.optimizeImage); + addOptimizeImageParams(params, putOptions.optimizeImage); - const response = await requestApi( + const response = await requestApi( `/put-from-url?${params.toString()}`, { method: 'POST', headers, - signal: options.abortSignal, + signal: putOptions.abortSignal, }, - options, + putOptions, ); return {