Skip to content

Commit 04ca1f0

Browse files
shawnfeldmanShawn Feldmanvvoclaude
authored
feat(blob): Add private storage (#924)
* [blob] add private blobs * browser * changeset * bypass * latest * main * remove live tests * multi part tests * add get by blob * update blob with streams * add access type * allow download urls and simplify typing * update * update * feat(blob): add raw headers to get() response Add headers property to GetBlobResult interface, returning the raw Headers object from the fetch response for accessing additional metadata like ETag or x-vercel-* headers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(blob): add optional headers option to get() Allow passing additional headers to the fetch request via an optional headers option. Documented as an advanced feature since most users won't need it. The authorization header is always set automatically. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ensure createFolder requires access property now, it's a write like put * [blob] feat: add useCache option to bypass CDN cache (#954) * [blob] feat: add useCache option to bypass content-cache Add a `useCache` option to `blob.get()` that controls whether to use the content-cache layer: - `useCache: true` (default) = normal caching behavior - `useCache: false` = appends ?cache=0 to bypass cache Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: clarify useCache only works for private blobs * Apply suggestion from @vvo * docs: use opaque terminology for cache (edge cache vs origin storage) * docs: use CDN cache terminology --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * add tests for private, fix method * update * update * fix(blob): add etag to get() response Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * update * update * update ts docs * conditional gets * add conditional reads * changeset * update --------- Co-authored-by: Shawn Feldman <shawn.feldman@vercel.com> Co-authored-by: Vincent Voyer <vincent@codeagain.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 29ba4ec commit 04ca1f0

11 files changed

Lines changed: 1254 additions & 29 deletions

File tree

.changeset/all-parts-occur.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@vercel/blob": minor
3+
---
4+
5+
Add private storage support (beta), a new `get()` method, and conditional gets
6+
7+
**Private storage (beta)**
8+
9+
You can now upload and read private blobs by setting `access: 'private'` on `put()` and `get()`. Private blobs require authentication to access — they are not publicly accessible via their URL.
10+
11+
**New `get()` method**
12+
13+
Fetch blob content by URL or pathname. Returns a `ReadableStream` along with blob metadata (url, pathname, contentType, size, etag, etc.).
14+
15+
**Conditional gets with `ifNoneMatch`**
16+
17+
Pass an `ifNoneMatch` option to `get()` with a previously received ETag. When the blob hasn't changed, the response returns `statusCode: 304` with `stream: null`, avoiding unnecessary re-downloads.
18+
19+
**Example**
20+
21+
```ts
22+
import { put, get } from '@vercel/blob';
23+
24+
// Upload a private blob
25+
const blob = await put('user123/avatar.png', file, { access: 'private' });
26+
27+
// Read it back
28+
const response = await get(blob.pathname, { access: 'private' });
29+
// response.stream — ReadableStream of the blob content
30+
// response.blob — metadata (url, pathname, contentType, size, etag, ...)
31+
32+
// Conditional get — skip download if unchanged
33+
const cached = await get(blob.pathname, {
34+
access: 'private',
35+
ifNoneMatch: response.blob.etag,
36+
});
37+
if (cached.statusCode === 304) {
38+
// Blob hasn't changed, reuse previous data
39+
}
40+
```
41+
42+
Learn more: https://vercel.com/docs/vercel-blob/private-storage

packages/blob/src/client.browser.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,69 @@ describe('client', () => {
9595
'x-api-blob-request-attempt': '0',
9696
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
9797
'x-api-version': '12',
98+
'x-vercel-blob-access': 'public',
99+
},
100+
method: 'PUT',
101+
},
102+
);
103+
});
104+
105+
it('should upload a file with private access', async () => {
106+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
107+
jest
108+
.fn()
109+
.mockResolvedValueOnce({
110+
status: 200,
111+
ok: true,
112+
json: () =>
113+
Promise.resolve({
114+
type: 'blob.generate-client-token',
115+
clientToken: 'vercel_blob_client_fake_123',
116+
}),
117+
})
118+
.mockResolvedValueOnce({
119+
status: 200,
120+
ok: true,
121+
json: () =>
122+
Promise.resolve({
123+
url: `https://storeId.public.blob.vercel-storage.com/superfoo.txt`,
124+
downloadUrl: `https://storeId.public.blob.vercel-storage.com/superfoo.txt?download=1`,
125+
pathname: 'foo.txt',
126+
contentType: 'text/plain',
127+
contentDisposition: 'attachment; filename="foo.txt"',
128+
etag: '"abc123"',
129+
}),
130+
}),
131+
);
132+
133+
await expect(
134+
upload('foo.txt', 'Test file data', {
135+
access: 'private',
136+
handleUploadUrl: '/api/upload',
137+
}),
138+
).resolves.toMatchInlineSnapshot(`
139+
{
140+
"contentDisposition": "attachment; filename="foo.txt"",
141+
"contentType": "text/plain",
142+
"downloadUrl": "https://storeId.public.blob.vercel-storage.com/superfoo.txt?download=1",
143+
"etag": ""abc123"",
144+
"pathname": "foo.txt",
145+
"url": "https://storeId.public.blob.vercel-storage.com/superfoo.txt",
146+
}
147+
`);
148+
149+
expect(fetchMock).toHaveBeenCalledTimes(2);
150+
expect(fetchMock).toHaveBeenNthCalledWith(
151+
2,
152+
'https://vercel.com/api/blob/?pathname=foo.txt',
153+
{
154+
body: 'Test file data',
155+
headers: {
156+
authorization: 'Bearer vercel_blob_client_fake_123',
157+
'x-api-blob-request-attempt': '0',
158+
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
159+
'x-api-version': '12',
160+
'x-vercel-blob-access': 'private',
98161
},
99162
method: 'PUT',
100163
},
@@ -205,12 +268,14 @@ describe('client', () => {
205268
1,
206269
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
207270
{
271+
duplex: undefined,
208272
headers: {
209273
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
210274
'x-api-blob-request-attempt': '0',
211275
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
212276
'x-api-version': '12',
213277
'x-mpu-action': 'create',
278+
'x-vercel-blob-access': 'public',
214279
},
215280
method: 'POST',
216281
signal: undefined,
@@ -222,6 +287,7 @@ describe('client', () => {
222287
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
223288
{
224289
body: 'data1',
290+
duplex: undefined,
225291
headers: {
226292
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
227293
'x-api-blob-request-attempt': '0',
@@ -231,6 +297,7 @@ describe('client', () => {
231297
'x-mpu-key': 'key',
232298
'x-mpu-upload-id': 'uploadId',
233299
'x-mpu-part-number': '1',
300+
'x-vercel-blob-access': 'public',
234301
},
235302
method: 'POST',
236303
signal: internalAbortSignal,
@@ -241,6 +308,7 @@ describe('client', () => {
241308
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
242309
{
243310
body: 'data2',
311+
duplex: undefined,
244312
headers: {
245313
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
246314
'x-api-blob-request-attempt': '0',
@@ -250,6 +318,7 @@ describe('client', () => {
250318
'x-mpu-key': 'key',
251319
'x-mpu-upload-id': 'uploadId',
252320
'x-mpu-part-number': '2',
321+
'x-vercel-blob-access': 'public',
253322
},
254323
method: 'POST',
255324
signal: internalAbortSignal,
@@ -263,6 +332,7 @@ describe('client', () => {
263332
{ etag: 'etag1', partNumber: 1 },
264333
{ etag: 'etag2', partNumber: 2 },
265334
]),
335+
duplex: undefined,
266336
headers: {
267337
'content-type': 'application/json',
268338
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
@@ -272,6 +342,7 @@ describe('client', () => {
272342
'x-mpu-action': 'complete',
273343
'x-mpu-key': 'key',
274344
'x-mpu-upload-id': 'uploadId',
345+
'x-vercel-blob-access': 'public',
275346
},
276347
method: 'POST',
277348
signal: undefined,
@@ -347,12 +418,14 @@ describe('client', () => {
347418
1,
348419
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
349420
{
421+
duplex: undefined,
350422
headers: {
351423
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
352424
'x-api-blob-request-attempt': '0',
353425
'x-api-blob-request-id': `fake:${Date.now()}:${requestId}`,
354426
'x-api-version': '12',
355427
'x-mpu-action': 'create',
428+
'x-vercel-blob-access': 'public',
356429
},
357430
method: 'POST',
358431
signal: undefined,
@@ -364,6 +437,7 @@ describe('client', () => {
364437
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
365438
{
366439
body: 'data1',
440+
duplex: undefined,
367441
headers: {
368442
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
369443
'x-api-blob-request-attempt': '0',
@@ -373,6 +447,7 @@ describe('client', () => {
373447
'x-mpu-key': 'key',
374448
'x-mpu-upload-id': 'uploadId',
375449
'x-mpu-part-number': '1',
450+
'x-vercel-blob-access': 'public',
376451
},
377452
method: 'POST',
378453
signal: internalAbortSignal,
@@ -383,6 +458,7 @@ describe('client', () => {
383458
'https://vercel.com/api/blob/mpu?pathname=foo.txt',
384459
{
385460
body: 'data2',
461+
duplex: undefined,
386462
headers: {
387463
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
388464
'x-api-blob-request-attempt': '0',
@@ -392,6 +468,7 @@ describe('client', () => {
392468
'x-mpu-key': 'key',
393469
'x-mpu-upload-id': 'uploadId',
394470
'x-mpu-part-number': '2',
471+
'x-vercel-blob-access': 'public',
395472
},
396473
method: 'POST',
397474
signal: internalAbortSignal,
@@ -405,6 +482,7 @@ describe('client', () => {
405482
{ etag: 'etag1', partNumber: 1 },
406483
{ etag: 'etag2', partNumber: 2 },
407484
]),
485+
duplex: undefined,
408486
headers: {
409487
'content-type': 'application/json',
410488
authorization: 'Bearer vercel_blob_client_fake_token_for_test',
@@ -414,6 +492,7 @@ describe('client', () => {
414492
'x-mpu-action': 'complete',
415493
'x-mpu-key': 'key',
416494
'x-mpu-upload-id': 'uploadId',
495+
'x-vercel-blob-access': 'public',
417496
},
418497
method: 'POST',
419498
signal: undefined,

packages/blob/src/client.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import * as crypto from 'crypto';
44
// the `undici` module will be replaced with https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
55
// for browser contexts. See ./undici-browser.js and ./package.json
66
import { fetch } from 'undici';
7-
import type { BlobCommandOptions, WithUploadProgress } from './helpers';
7+
import type {
8+
BlobAccessType,
9+
BlobCommandOptions,
10+
WithUploadProgress,
11+
} from './helpers';
812
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
913
import type { CommonCompleteMultipartUploadOptions } from './multipart/complete';
1014
import { createCompleteMultipartUploadMethod } from './multipart/complete';
@@ -22,8 +26,10 @@ import type { PutBlobResult } from './put-helpers';
2226
export interface ClientCommonCreateBlobOptions {
2327
/**
2428
* Whether the blob should be publicly accessible.
29+
* - 'public': The blob will be publicly accessible via its URL.
30+
* - 'private': The blob will require authentication to access.
2531
*/
26-
access: 'public';
32+
access: BlobAccessType;
2733
/**
2834
* Defines the content type of the blob. By default, this value is inferred from the pathname.
2935
* Sent as the 'content-type' header when downloading a blob.
@@ -97,7 +103,7 @@ export type ClientPutCommandOptions = ClientCommonPutOptions &
97103
* @param pathname - The pathname to upload the blob to, including the extension. This will influence the URL of your blob.
98104
* @param body - The content of your blob. Can be a string, File, Blob, Buffer or ReadableStream.
99105
* @param options - Configuration options including:
100-
* - access - (Required) Must be 'public' as blobs are publicly accessible.
106+
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
101107
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
102108
* - contentType - (Optional) The media type for the blob. By default, it's derived from the pathname.
103109
* - 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.
@@ -121,7 +127,7 @@ export type ClientCreateMultipartUploadCommandOptions =
121127
*
122128
* @param pathname - A string specifying the path inside the blob store. This will be the base value of the return URL and includes the filename and extension.
123129
* @param options - Configuration options including:
124-
* - access - (Required) Must be 'public' as blobs are publicly accessible.
130+
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
125131
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
126132
* - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension.
127133
* - abortSignal - (Optional) AbortSignal to cancel the operation.
@@ -141,7 +147,7 @@ export const createMultipartUpload =
141147
*
142148
* @param pathname - A string specifying the path inside the blob store. This will be the base value of the return URL and includes the filename and extension.
143149
* @param options - Configuration options including:
144-
* - access - (Required) Must be 'public' as blobs are publicly accessible.
150+
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
145151
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
146152
* - contentType - (Optional) The media type for the file. If not specified, it's derived from the file extension.
147153
* - abortSignal - (Optional) AbortSignal to cancel the operation.
@@ -174,7 +180,7 @@ type ClientMultipartUploadCommandOptions = ClientCommonCreateBlobOptions &
174180
* @param pathname - Same value as the pathname parameter passed to createMultipartUpload. This will influence the final URL of your blob.
175181
* @param body - A blob object as ReadableStream, String, ArrayBuffer or Blob based on these supported body types. Each part must be a minimum of 5MB, except the last one which can be smaller.
176182
* @param options - Configuration options including:
177-
* - access - (Required) Must be 'public' as blobs are publicly accessible.
183+
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
178184
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
179185
* - uploadId - (Required) A string returned from createMultipartUpload which identifies the multipart upload.
180186
* - key - (Required) A string returned from createMultipartUpload which identifies the blob object.
@@ -205,7 +211,7 @@ type ClientCompleteMultipartUploadCommandOptions =
205211
* @param pathname - Same value as the pathname parameter passed to createMultipartUpload.
206212
* @param parts - An array containing all the uploaded parts information from previous uploadPart calls. Each part must have properties etag and partNumber.
207213
* @param options - Configuration options including:
208-
* - access - (Required) Must be 'public' as blobs are publicly accessible.
214+
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
209215
* - token - (Required) A client token generated by your server using the generateClientTokenFromReadWriteToken method.
210216
* - uploadId - (Required) A string returned from createMultipartUpload which identifies the multipart upload.
211217
* - key - (Required) A string returned from createMultipartUpload which identifies the blob object.
@@ -256,7 +262,7 @@ export type UploadOptions = ClientCommonPutOptions & CommonUploadOptions;
256262
* @param pathname - The pathname to upload the blob to. This includes the filename and extension.
257263
* @param body - The contents of your blob. This has to be a supported fetch body type (string, Blob, File, ArrayBuffer, etc).
258264
* @param options - Configuration options including:
259-
* - access - (Required) Must be 'public' as blobs are publicly accessible.
265+
* - access - (Required) Must be 'public' or 'private'. Public blobs are accessible via URL, private blobs require authentication.
260266
* - handleUploadUrl - (Required) A string specifying the route to call for generating client tokens for client uploads.
261267
* - clientPayload - (Optional) A string to be sent to your handleUpload server code. Example use-case: attaching the post id an image relates to.
262268
* - headers - (Optional) An object containing custom headers to be sent with the request to your handleUpload route. Example use-case: sending Authorization headers.

packages/blob/src/copy.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ export async function copy(
3333
throw new BlobError('missing options, see usage');
3434
}
3535

36-
if (options.access !== 'public') {
37-
throw new BlobError('access must be "public"');
36+
if (options.access !== 'public' && options.access !== 'private') {
37+
throw new BlobError(
38+
'access must be "private" or "public", see https://vercel.com/docs/vercel-blob',
39+
);
3840
}
3941

4042
if (toPathname.length > MAXIMUM_PATHNAME_LENGTH) {
@@ -53,6 +55,9 @@ export async function copy(
5355

5456
const headers: Record<string, string> = {};
5557

58+
// access is always required, so always add it to headers
59+
headers['x-vercel-blob-access'] = options.access;
60+
5661
if (options.addRandomSuffix !== undefined) {
5762
headers['x-add-random-suffix'] = options.addRandomSuffix ? '1' : '0';
5863
}

packages/blob/src/create-folder.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { requestApi } from './api';
2-
import type { BlobCommandOptions } from './helpers';
2+
import type { BlobAccessType, CommonCreateBlobOptions } from './helpers';
3+
import { BlobError } from './helpers';
34
import { type PutBlobApiResponse, putOptionHeaderMap } from './put-helpers';
45

6+
export type CreateFolderCommandOptions = Pick<
7+
CommonCreateBlobOptions,
8+
'token' | 'abortSignal'
9+
> & {
10+
/** @defaultValue 'public' — kept for backward compatibility */
11+
access?: BlobAccessType;
12+
};
13+
514
export interface CreateFolderResult {
615
pathname: string;
716
url: string;
@@ -12,16 +21,21 @@ export interface CreateFolderResult {
1221
*
1322
* Use the resulting `url` to delete the folder, just like you would delete a blob.
1423
* @param pathname - Can be user1/ or user1/avatars/
15-
* @param options - Additional options like `token`
24+
* @param options - Additional options including required `access` ('public' or 'private') and optional `token`
1625
*/
26+
// access defaults to 'public' for backward compatibility with callers
27+
// that don't pass options (pre-private-storage API)
1728
export async function createFolder(
1829
pathname: string,
19-
options: BlobCommandOptions = {},
30+
options: CreateFolderCommandOptions = { access: 'public' },
2031
): Promise<CreateFolderResult> {
32+
const access = options.access ?? 'public';
33+
2134
const folderPathname = pathname.endsWith('/') ? pathname : `${pathname}/`;
2235

2336
const headers: Record<string, string> = {};
2437

38+
headers[putOptionHeaderMap.access] = access;
2539
headers[putOptionHeaderMap.addRandomSuffix] = '0';
2640

2741
const params = new URLSearchParams({ pathname: folderPathname });

0 commit comments

Comments
 (0)