Skip to content

Commit 73cab02

Browse files
committed
add blob deleteAfter lifecycle option
1 parent f23cb89 commit 73cab02

11 files changed

Lines changed: 224 additions & 56 deletions

File tree

.changeset/blob-delete-after.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@vercel/blob': minor
3+
---
4+
5+
Add a `deleteAfter` upload option for automatic Blob deletion after 1, 7, or 30 days.

packages/blob/src/api.node.test.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,21 +116,24 @@ describe('api', () => {
116116
[700, 'store_not_found', BlobStoreNotFoundError],
117117
[800, 'not_allowed', BlobUnknownError],
118118
[800, 'not_allowed', BlobUnknownError],
119-
])(`should not retry '%s %s' response error response`, async (status, code, error, message = '') => {
120-
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
121-
jest.fn().mockResolvedValue({
122-
status,
123-
ok: false,
124-
json: () => Promise.resolve({ error: { code, message } }),
125-
}),
126-
);
119+
])(
120+
`should not retry '%s %s' response error response`,
121+
async (status, code, error, message = '') => {
122+
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
123+
jest.fn().mockResolvedValue({
124+
status,
125+
ok: false,
126+
json: () => Promise.resolve({ error: { code, message } }),
127+
}),
128+
);
127129

128-
await expect(
129-
requestApi('/api', { method: 'GET' }, { token: '123' }),
130-
).rejects.toThrow(error);
130+
await expect(
131+
requestApi('/api', { method: 'GET' }, { token: '123' }),
132+
).rejects.toThrow(error);
131133

132-
expect(fetchMock).toHaveBeenCalledTimes(1);
133-
});
134+
expect(fetchMock).toHaveBeenCalledTimes(1);
135+
},
136+
);
134137
});
135138
});
136139

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ describe('client', () => {
604604
}),
605605
).rejects.toThrow(
606606
new Error(
607-
"Vercel Blob: client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating client tokens.",
607+
"Vercel Blob: client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite`, `ifMatch` or `deleteAfter`. Configure these options at the server side when generating client tokens.",
608608
),
609609
);
610610
});

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@ describe('client uploads', () => {
9090
validUntil: 1672531230000,
9191
});
9292
});
93+
94+
it('includes deleteAfter in the client token payload', async () => {
95+
const uploadToken = await generateClientTokenFromReadWriteToken({
96+
pathname: 'foo.txt',
97+
deleteAfter: '1 day',
98+
token: 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678',
99+
});
100+
101+
expect(getPayloadFromClientToken(uploadToken)).toEqual({
102+
pathname: 'foo.txt',
103+
deleteAfter: '1 day',
104+
validUntil: 1672531230000,
105+
});
106+
});
93107
});
94108

95109
describe('handleUpload', () => {
@@ -276,6 +290,40 @@ describe('client uploads', () => {
276290
});
277291
});
278292

293+
it('allows deleteAfter from onBeforeGenerateToken', async () => {
294+
const token =
295+
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';
296+
const jsonResponse = await handleUpload({
297+
token,
298+
request: {
299+
headers: { 'x-vercel-signature': '123' },
300+
} as unknown as IncomingMessage,
301+
body: {
302+
type: 'blob.generate-client-token',
303+
payload: {
304+
pathname: 'newfile.txt',
305+
clientPayload: null,
306+
multipart: false,
307+
},
308+
},
309+
onBeforeGenerateToken: async () => {
310+
return {
311+
deleteAfter: '7 days',
312+
};
313+
},
314+
});
315+
316+
expect(jsonResponse.type).toEqual('blob.generate-client-token');
317+
318+
const decodedPayload = getPayloadFromClientToken(
319+
(jsonResponse.type === 'blob.generate-client-token' &&
320+
jsonResponse.clientToken) ||
321+
'',
322+
);
323+
324+
expect(decodedPayload.deleteAfter).toEqual('7 days');
325+
});
326+
279327
it('ignores client callbackUrl when server provides one', async () => {
280328
const token =
281329
'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678';

packages/blob/src/client.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ import { fetch } from 'undici';
77
import type {
88
BlobAccessType,
99
BlobCommandOptions,
10+
BlobDeleteAfter,
1011
WithUploadProgress,
1112
} from './helpers';
12-
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';
13+
import {
14+
BlobError,
15+
getTokenFromOptionsOrEnv,
16+
serializeBlobDeleteAfter,
17+
} from './helpers';
1318
import type { CommonCompleteMultipartUploadOptions } from './multipart/complete';
1419
import { createCompleteMultipartUploadMethod } from './multipart/complete';
1520
import { createCreateMultipartUploadMethod } from './multipart/create';
@@ -19,6 +24,8 @@ import { createUploadPartMethod } from './multipart/upload';
1924
import { createPutMethod } from './put';
2025
import type { PutBlobResult } from './put-helpers';
2126

27+
export type { BlobDeleteAfter } from './helpers';
28+
2229
/**
2330
* Interface for put, upload and multipart upload operations.
2431
* This type omits all options that are encoded in the client token.
@@ -82,10 +89,14 @@ function createPutExtraChecks<
8289
// @ts-expect-error -- Runtime check for DX.
8390
options.allowOverwrite !== undefined ||
8491
// @ts-expect-error -- Runtime check for DX.
85-
options.cacheControlMaxAge !== undefined
92+
options.cacheControlMaxAge !== undefined ||
93+
// @ts-expect-error -- Runtime check for DX.
94+
options.ifMatch !== undefined ||
95+
// @ts-expect-error -- Runtime check for DX.
96+
options.deleteAfter !== undefined
8697
) {
8798
throw new BlobError(
88-
`${methodName} doesn't allow \`addRandomSuffix\`, \`cacheControlMaxAge\` or \`allowOverwrite\`. Configure these options at the server side when generating client tokens.`,
99+
`${methodName} doesn't allow \`addRandomSuffix\`, \`cacheControlMaxAge\`, \`allowOverwrite\`, \`ifMatch\` or \`deleteAfter\`. Configure these options at the server side when generating client tokens.`,
89100
);
90101
}
91102
};
@@ -289,10 +300,12 @@ export const upload = createPutMethod<UploadOptions>({
289300
// @ts-expect-error -- Runtime check for DX.
290301
options.cacheControlMaxAge !== undefined ||
291302
// @ts-expect-error -- Runtime check for DX.
292-
options.ifMatch !== undefined
303+
options.ifMatch !== undefined ||
304+
// @ts-expect-error -- Runtime check for DX.
305+
options.deleteAfter !== undefined
293306
) {
294307
throw new BlobError(
295-
"client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite` or `ifMatch`. Configure these options at the server side when generating client tokens.",
308+
"client/`upload` doesn't allow `addRandomSuffix`, `cacheControlMaxAge`, `allowOverwrite`, `ifMatch` or `deleteAfter`. Configure these options at the server side when generating client tokens.",
296309
);
297310
}
298311
},
@@ -534,6 +547,7 @@ export interface HandleUploadOptions {
534547
| 'allowOverwrite'
535548
| 'cacheControlMaxAge'
536549
| 'ifMatch'
550+
| 'deleteAfter'
537551
> & { tokenPayload?: string | null; callbackUrl?: string }
538552
>;
539553

@@ -743,6 +757,7 @@ function isAbsoluteUrl(url: string): boolean {
743757
* - allowOverwrite - (Optional) Whether to allow overwriting existing blobs. Defaults to false.
744758
* - cacheControlMaxAge - (Optional) Number of seconds to configure cache duration. Defaults to one month.
745759
* - ifMatch - (Optional) Only write if the ETag matches (optimistic concurrency control).
760+
* - deleteAfter - (Optional) Automatically delete the blob after "1 day", "7 days", or "30 days".
746761
* @returns A promise that resolves to the generated client token string which can be used in client-side upload operations.
747762
*/
748763
export async function generateClientTokenFromReadWriteToken({
@@ -785,6 +800,8 @@ export async function generateClientTokenFromReadWriteToken({
785800
);
786801
}
787802

803+
serializeBlobDeleteAfter(argsWithoutToken.deleteAfter);
804+
788805
const payload = Buffer.from(
789806
JSON.stringify({
790807
...argsWithoutToken,
@@ -860,6 +877,11 @@ export interface GenerateClientTokenOptions extends BlobCommandOptions {
860877
* If the ETag doesn't match, a `BlobPreconditionFailedError` will be thrown.
861878
*/
862879
ifMatch?: string;
880+
881+
/**
882+
* Automatically delete the blob after the configured duration.
883+
*/
884+
deleteAfter?: BlobDeleteAfter;
863885
}
864886

865887
/**

packages/blob/src/copy.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { MAXIMUM_PATHNAME_LENGTH, requestApi } from './api';
22
import type { CommonCreateBlobOptions } from './helpers';
33
import { BlobError, disallowedPathnameCharacters } from './helpers';
4+
import { createPutHeaders } from './put-helpers';
45

56
export type CopyCommandOptions = CommonCreateBlobOptions;
67

@@ -22,7 +23,7 @@ export interface CopyBlobResult {
2223
*
2324
* @param fromUrlOrPathname - The blob URL (or pathname) to copy. You can only copy blobs that are in the store, that your 'BLOB_READ_WRITE_TOKEN' has access to.
2425
* @param toPathname - The pathname to copy the blob to. This includes the filename.
25-
* @param options - Additional options. The copy method will not preserve any metadata configuration (e.g.: 'cacheControlMaxAge') of the source blob. If you want to copy the metadata, you need to define it here again.
26+
* @param options - Additional options. The copy method will not preserve any metadata configuration (e.g.: 'cacheControlMaxAge' or 'deleteAfter') of the source blob. If you want to copy the metadata, you need to define it here again.
2627
*/
2728
export async function copy(
2829
fromUrlOrPathname: string,
@@ -53,30 +54,17 @@ export async function copy(
5354
}
5455
}
5556

56-
const headers: Record<string, string> = {};
57-
58-
// access is always required, so always add it to headers
59-
headers['x-vercel-blob-access'] = options.access;
60-
61-
if (options.addRandomSuffix !== undefined) {
62-
headers['x-add-random-suffix'] = options.addRandomSuffix ? '1' : '0';
63-
}
64-
65-
if (options.allowOverwrite !== undefined) {
66-
headers['x-allow-overwrite'] = options.allowOverwrite ? '1' : '0';
67-
}
68-
69-
if (options.contentType) {
70-
headers['x-content-type'] = options.contentType;
71-
}
72-
73-
if (options.cacheControlMaxAge !== undefined) {
74-
headers['x-cache-control-max-age'] = options.cacheControlMaxAge.toString();
75-
}
76-
77-
if (options.ifMatch) {
78-
headers['x-if-match'] = options.ifMatch;
79-
}
57+
const headers = createPutHeaders(
58+
[
59+
'cacheControlMaxAge',
60+
'addRandomSuffix',
61+
'allowOverwrite',
62+
'contentType',
63+
'ifMatch',
64+
'deleteAfter',
65+
],
66+
options,
67+
);
8068

8169
const params = new URLSearchParams({
8270
pathname: toPathname,

packages/blob/src/create-folder.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { requestApi } from './api';
22
import type { BlobAccessType, CommonCreateBlobOptions } from './helpers';
3-
import { BlobError } from './helpers';
43
import { type PutBlobApiResponse, putOptionHeaderMap } from './put-helpers';
54

65
export type CreateFolderCommandOptions = Pick<

packages/blob/src/helpers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface BlobCommandOptions {
3030
*/
3131
export type BlobAccessType = 'public' | 'private';
3232

33+
export type BlobDeleteAfter = '1 day' | '7 days' | '30 days';
34+
3335
// shared interface for put, copy and multipart upload
3436
export interface CommonCreateBlobOptions extends BlobCommandOptions {
3537
/**
@@ -72,6 +74,10 @@ export interface CommonCreateBlobOptions extends BlobCommandOptions {
7274
* The maximum allowed value is 5TB.
7375
*/
7476
maximumSizeInBytes?: number;
77+
/**
78+
* Automatically delete the blob after the configured duration.
79+
*/
80+
deleteAfter?: BlobDeleteAfter;
7581
}
7682

7783
/**
@@ -148,6 +154,24 @@ export class BlobError extends Error {
148154
}
149155
}
150156

157+
const supportedDeleteAfterValues = ['1 day', '7 days', '30 days'];
158+
159+
export function serializeBlobDeleteAfter(
160+
deleteAfter: BlobDeleteAfter | undefined,
161+
): string | undefined {
162+
if (!deleteAfter) {
163+
return undefined;
164+
}
165+
166+
if (!supportedDeleteAfterValues.includes(deleteAfter)) {
167+
throw new BlobError(
168+
'`deleteAfter` must be one of "1 day", "7 days", or "30 days".',
169+
);
170+
}
171+
172+
return deleteAfter;
173+
}
174+
151175
/**
152176
* Generates a download URL for a blob.
153177
* The download URL includes a ?download=1 parameter which causes browsers to download

0 commit comments

Comments
 (0)