Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/blob-optimize-image.md
Original file line number Diff line number Diff line change
@@ -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' },
});
```
10 changes: 10 additions & 0 deletions packages/blob/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
248 changes: 248 additions & 0 deletions packages/blob/src/put-from-url.node.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
let body = '';
mockClient
.intercept({
path: () => true,
method: 'POST',
})
.reply(200, (req) => {
path = req.path;
headers = req.headers as Record<string, string>;
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<string, string> = {};
mockClient
.intercept({
path: () => true,
method: 'POST',
})
.reply(200, (req) => {
headers = req.headers as Record<string, string>;
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<string, string> = {};
mockClient
.intercept({
path: () => true,
method: 'POST',
})
.reply(200, (req) => {
path = req.path;
headers = req.headers as Record<string, string>;
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<string, string> = {};
mockClient
.intercept({
path: () => true,
method: 'POST',
})
.reply(200, (req) => {
headers = req.headers as Record<string, string>;
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<string, string> = {};
mockClient
.intercept({
path: () => true,
method: 'POST',
})
.reply(200, (req) => {
headers = req.headers as Record<string, string>;
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');
});
});
});
90 changes: 90 additions & 0 deletions packages/blob/src/put-from-url.ts
Original file line number Diff line number Diff line change
@@ -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<CommonCreateBlobOptions, 'contentType'> {
/**
* 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<PutFromUrlBlobResult> {
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<PutBlobApiResponse>(
`/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,
};
}
Loading
Loading