Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/presigned-get-use-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vercel/blob': patch
---

Add a `useCache` option to `presignUrl()` for `get` operations. When `useCache: false`, the presigned URL includes a `cache=0` query param so fetches bypass the CDN cache and read the latest content directly from origin storage. Like `get()`, the bypass only applies to private blobs. The param is not part of the signed payload, so holders of a presigned URL can also add or remove it manually.
51 changes: 51 additions & 0 deletions packages/blob/src/get.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,57 @@ describe('presignUrl (get)', () => {
);
});

it('appends cache=0 when useCache is false on a private blob', async () => {
const pathname = 'media/photo.png';
const token = makeSignedToken(pathname);
const { presignedUrl: url } = await presignUrl(token, {
operation: 'get',
pathname,
access: 'private',
useCache: false,
});

const parsed = new URL(url);
expect(parsed.searchParams.get('cache')).toBe('0');

// cache is not part of the signed payload: the signature is identical to
// a URL presigned without useCache.
const { presignedUrl: withoutBypass } = await presignUrl(token, {
operation: 'get',
pathname,
access: 'private',
});
const withoutBypassParsed = new URL(withoutBypass);
expect(withoutBypassParsed.searchParams.get('cache')).toBeNull();
expect(parsed.searchParams.get('vercel-blob-signature')).toBe(
withoutBypassParsed.searchParams.get('vercel-blob-signature'),
);
});

it('does not append cache=0 when useCache is true or omitted', async () => {
const pathname = 'media/photo.png';
const token = makeSignedToken(pathname);
const { presignedUrl: url } = await presignUrl(token, {
operation: 'get',
pathname,
access: 'private',
useCache: true,
});
expect(new URL(url).searchParams.get('cache')).toBeNull();
});

it('ignores useCache: false for public blobs (CDN only supports the bypass for private)', async () => {
const pathname = 'a.png';
const token = makeSignedToken(pathname);
const { presignedUrl: url } = await presignUrl(token, {
operation: 'get',
pathname,
access: 'public',
useCache: false,
});
expect(new URL(url).searchParams.get('cache')).toBeNull();
});

it('rejects an invalid delegation token', async () => {
const token = {
delegationToken: 'not-a-jwt',
Expand Down
22 changes: 21 additions & 1 deletion packages/blob/src/signed-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,15 @@ export type PresignGetUrlOptions = {
* Omitted on the wire when equal to the delegation ceiling (server defaults to delegation).
*/
validUntil?: number;
/**
* Whether the presigned URL may be served from CDN cache. When false, a
* `cache=0` query param is appended so fetches read the latest content
* directly from origin storage. The CDN only supports the bypass for
* private blobs, so it's ignored for public ones. The param is not part
* of the signed payload: whoever holds the URL can add or remove it.
* @defaultValue true
*/
useCache?: boolean;
};

/**
Expand Down Expand Up @@ -430,14 +439,25 @@ function buildPresignedGetUrl(
presignedUrlPayload: PresignedUrlPayload,
options: {
access: 'public' | 'private';
useCache?: boolean;
},
): string {
const storeId = parseStoreIdFromDelegationToken(
presignedUrlPayload.delegationToken,
);
const blobUrl = isUrl(pathnameOrUrl)
let blobUrl = isUrl(pathnameOrUrl)
? pathnameOrUrl
: constructBlobUrl(storeId, pathnameOrUrl, options.access);

// Same gating as get(): the CDN only supports the cache bypass for private
// blobs. The param is deliberately outside the signed payload, so adding it
// here doesn't affect signature verification.
if (options.useCache === false && options.access === 'private') {
const url = new URL(blobUrl);
url.searchParams.set('cache', '0');
blobUrl = url.toString();
}

return addPresignedParams(blobUrl, presignedUrlPayload);
}

Expand Down
Loading