diff --git a/packages/core/storage-js/src/lib/types.ts b/packages/core/storage-js/src/lib/types.ts index bb654bcb42..e2b2fd9f2b 100644 --- a/packages/core/storage-js/src/lib/types.ts +++ b/packages/core/storage-js/src/lib/types.ts @@ -27,6 +27,14 @@ export interface ListBucketOptions { search?: string } +export interface PurgeCacheOptions { + /** + * If true, purges only the transformations (resized/formatted variants) for the object or bucket, + * leaving the original cached file intact. If omitted, purges all cached versions. + */ + transformations?: true +} + /** * Represents an Analytics Bucket using Apache Iceberg table format. * Analytics buckets are optimized for analytical queries and data processing. diff --git a/packages/core/storage-js/src/packages/StorageBucketApi.ts b/packages/core/storage-js/src/packages/StorageBucketApi.ts index ddc10a6ae8..11cb90eb5d 100644 --- a/packages/core/storage-js/src/packages/StorageBucketApi.ts +++ b/packages/core/storage-js/src/packages/StorageBucketApi.ts @@ -2,7 +2,13 @@ import { DEFAULT_HEADERS } from '../lib/constants' import { StorageError } from '../lib/common/errors' import { Fetch, get, post, put, remove } from '../lib/common/fetch' import BaseApiClient from '../lib/common/BaseApiClient' -import { Bucket, BucketType, ListBucketOptions } from '../lib/types' +import { + Bucket, + BucketType, + FetchParameters, + ListBucketOptions, + PurgeCacheOptions, +} from '../lib/types' import { StorageClientOptions } from '../StorageClient' export default class StorageBucketApi extends BaseApiClient { @@ -390,6 +396,73 @@ export default class StorageBucketApi extends BaseApiClient { }) } + /** + * Purges the CDN cache for an entire bucket. + * + * Maps to `DELETE /cdn/{bucket}` on the Storage API. The server + * issues a CDN invalidation for the bucket and returns `{ message: 'success' }`. + * + * **Requires the `service_role` key.** The underlying endpoint enforces + * `service_role` JWT — calls made with the anon key or a user JWT will be + * rejected by the server. + * + * **Hosted CDN feature.** On self-hosted Supabase, the Storage service must + * have `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant + * feature enabled, otherwise the server returns an error. + * + * @category Storage + * @subcategory File Buckets + * @param id The unique identifier of the bucket you would like to purge from cache. + * @param options Optional purge cache options. + * @param options.transformations If true, purges only transformations (resized/formatted variants), leaving original cached files intact. + * @param parameters Optional fetch parameters such as an `AbortController` signal. + * @returns Promise with `{ data: { message }, error: null }` on success or `{ data: null, error }` on failure. + * + * @example Purge cache for an entire bucket + * ```js + * const { data, error } = await supabase + * .storage + * .purgeBucketCache('avatars') + * ``` + * + * @example Purge only transformations for an entire bucket + * ```js + * const { data, error } = await supabase + * .storage + * .purgeBucketCache('avatars', { transformations: true }) + * ``` + */ + async purgeBucketCache( + id: string, + options?: PurgeCacheOptions, + parameters?: FetchParameters + ): Promise< + | { + data: { message: string } + error: null + } + | { + data: null + error: StorageError + } + > { + return this.handleOperation(async () => { + const query = new URLSearchParams() + if (options?.transformations) { + query.set('transformations', 'true') + } + const queryString = query.toString() + + return await remove( + this.fetch, + `${this.url}/cdn/${id}${queryString ? `?${queryString}` : ''}`, + {}, + { headers: this.headers }, + parameters + ) + }) + } + private listBucketOptionsToQueryString(options?: ListBucketOptions): string { const params: Record = {} if (options) { diff --git a/packages/core/storage-js/src/packages/StorageFileApi.ts b/packages/core/storage-js/src/packages/StorageFileApi.ts index 6227bc0995..ca0d6ee439 100644 --- a/packages/core/storage-js/src/packages/StorageFileApi.ts +++ b/packages/core/storage-js/src/packages/StorageFileApi.ts @@ -19,6 +19,7 @@ import { Camelize, SearchV2Options, SearchV2Result, + PurgeCacheOptions, } from '../lib/types' import BlobDownloadBuilder from './BlobDownloadBuilder' @@ -1160,6 +1161,79 @@ export default class StorageFileApi extends BaseApiClient { }) } + /** + * Purges the CDN cache for a single object in this bucket. + * + * Maps to `DELETE /cdn/{bucket}/{path}` on the Storage API. The server + * issues a CDN invalidation for the object and returns `{ message: 'success' }`. + * + * **Requires the `service_role` key.** The underlying endpoint enforces + * `service_role` JWT — calls made with the anon key or a user JWT will be + * rejected by the server. + * + * **Hosted CDN feature.** On self-hosted Supabase, the Storage service must + * have `CDN_PURGE_ENDPOINT_URL` configured and the `purgeCache` tenant + * feature enabled, otherwise the server returns an error. + * + * Operates on a single object path. There is no wildcard or recursion: pass + * the exact path of the object you want invalidated. + * + * @category Storage + * @subcategory File Buckets + * @param path The path (relative to the bucket) of the object to purge, e.g. `folder/avatar.png`. + * @param options Optional purge cache options. + * @param options.transformations If true, purges only transformations (resized/formatted variants), leaving the original cached file intact. + * @param parameters Optional fetch parameters such as an `AbortController` signal. + * @returns Promise with `{ data: { message }, error: null }` on success or `{ data: null, error }` on failure. + * + * @example Purge a single cached object + * ```js + * const { data, error } = await supabase + * .storage + * .from('avatars') + * .purgeCache('folder/avatar1.png') + * ``` + * + * @example Purge only transformations for a single object + * ```js + * const { data, error } = await supabase + * .storage + * .from('avatars') + * .purgeCache('folder/avatar1.png', { transformations: true }) + * ``` + */ + async purgeCache( + path: string, + options?: PurgeCacheOptions, + parameters?: FetchParameters + ): Promise< + | { + data: { message: string } + error: null + } + | { + data: null + error: StorageError + } + > { + return this.handleOperation(async () => { + const _path = this._getFinalPath(path) + const query = new URLSearchParams() + if (options?.transformations) { + query.set('transformations', 'true') + } + const queryString = query.toString() + + return await remove( + this.fetch, + `${this.url}/cdn/${_path}${queryString ? `?${queryString}` : ''}`, + {}, + { headers: this.headers }, + parameters + ) + }) + } + /** * Get file metadata * @param id the file id to retrieve metadata diff --git a/packages/core/storage-js/test/storageBucketApi.test.ts b/packages/core/storage-js/test/storageBucketApi.test.ts index 1090b1c9b0..d3df74a016 100644 --- a/packages/core/storage-js/test/storageBucketApi.test.ts +++ b/packages/core/storage-js/test/storageBucketApi.test.ts @@ -1,5 +1,5 @@ import { StorageClient } from '../src/index' -import { StorageUnknownError } from '../src/lib/common/errors' +import { StorageApiError, StorageUnknownError } from '../src/lib/common/errors' // Create a simple Response implementation for testing class MockResponse { @@ -539,4 +539,116 @@ describe('Bucket API Error Handling', () => { mockFn.mockRestore() }) }) + + describe('purgeBucketCache', () => { + const PURGE_URL = 'http://localhost:8000/storage/v1' + const BUCKET = 'avatars' + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('issues DELETE to /cdn/{bucket} and returns the server message', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.purgeBucketCache(BUCKET) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('surfaces server errors via StorageApiError', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + statusCode: '403', + error: 'Forbidden', + message: 'Feature not enabled', + }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.purgeBucketCache(BUCKET) + + expect(data).toBeNull() + expect(error).toBeInstanceOf(StorageApiError) + expect(error?.message).toBe('Feature not enabled') + }) + + it('appends transformations query param when transformations option is true', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.purgeBucketCache(BUCKET, { transformations: true }) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}?transformations=true`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('omits transformations query param when transformations option is not provided', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.purgeBucketCache(BUCKET) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('forwards the AbortController signal to fetch', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const controller = new AbortController() + const { error } = await client.purgeBucketCache(BUCKET, undefined, { + signal: controller.signal, + }) + + expect(error).toBeNull() + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}`, + expect.objectContaining({ method: 'DELETE', signal: controller.signal }) + ) + }) + }) }) diff --git a/packages/core/storage-js/test/storageFileApi.test.ts b/packages/core/storage-js/test/storageFileApi.test.ts index fe79aa9d06..1eb46a491c 100644 --- a/packages/core/storage-js/test/storageFileApi.test.ts +++ b/packages/core/storage-js/test/storageFileApi.test.ts @@ -1024,6 +1024,117 @@ describe('error handling', () => { }) }) +describe('purgeCache', () => { + const PURGE_URL = 'http://localhost:8000/storage/v1' + const BUCKET = 'avatars' + const PATH = 'folder/avatar.png' + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('issues DELETE to /cdn/{bucket}/{path} and returns the server message', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.from(BUCKET).purgeCache(PATH) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('surfaces server errors via StorageApiError', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ statusCode: '403', error: 'Forbidden', message: 'Feature not enabled' }), + { status: 403, headers: { 'Content-Type': 'application/json' } } + ) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.from(BUCKET).purgeCache(PATH) + + expect(data).toBeNull() + expect(error).toBeInstanceOf(StorageApiError) + expect(error?.message).toBe('Feature not enabled') + }) + + it('appends transformations query param when transformations option is true', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.from(BUCKET).purgeCache(PATH, { transformations: true }) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}?transformations=true`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('omits transformations query param when transformations option is not provided', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const { data, error } = await client.from(BUCKET).purgeCache(PATH) + + expect(error).toBeNull() + expect(data?.message).toBe('success') + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}`, + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('forwards the AbortController signal to fetch', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + global.fetch = fetchMock + + const client = new StorageClient(PURGE_URL, { apikey: 'service-role-token' }) + const controller = new AbortController() + const { error } = await client + .from(BUCKET) + .purgeCache(PATH, undefined, { signal: controller.signal }) + + expect(error).toBeNull() + expect(fetchMock).toHaveBeenCalledWith( + `${PURGE_URL}/cdn/${BUCKET}/${PATH}`, + expect.objectContaining({ method: 'DELETE', signal: controller.signal }) + ) + }) +}) + describe('StorageFileApi Edge Cases', () => { let storage: StorageClient