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.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 new file mode 100644 index 000000000..a2b6a71b7 --- /dev/null +++ b/packages/blob/src/put-from-url.ts @@ -0,0 +1,90 @@ +import { requestApi } from './api'; +import type { CommonCreateBlobOptions } from './helpers'; +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. + */ + 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. + * - 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. + * @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 { + const putOptions = await createPutOptions({ pathname, options }); + + if (!url) { + throw new BlobError('url is required'); + } + + if (!putOptions.optimizeImage) { + throw new BlobError('optimizeImage is required, see usage'); + } + + const headers = createPutHeaders( + ['cacheControlMaxAge', 'addRandomSuffix', 'allowOverwrite', 'ifMatch'], + putOptions, + ); + + const params = new URLSearchParams({ pathname, url }); + addOptimizeImageParams(params, putOptions.optimizeImage); + + const response = await requestApi( + `/put-from-url?${params.toString()}`, + { + method: 'POST', + headers, + signal: putOptions.abortSignal, + }, + putOptions, + ); + + 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,