diff --git a/docs/2.drivers/supabase.md b/docs/2.drivers/supabase.md new file mode 100644 index 00000000..a68a0236 --- /dev/null +++ b/docs/2.drivers/supabase.md @@ -0,0 +1,37 @@ +--- +icon: ri:supabase-line +--- + +# Supabase Storage + +> Store data in Supabase Storage. + +::read-more{to="https://supabase.com/docs/guides/storage"} +Learn more about Supabase Storage. +:: + +::warning +Supabase Storage driver is in beta. +:: + +To use it, you will need to install `@supabase/supabase-js` in your project + +```js +import { createStorage } from "unstorage"; +import supabaseStorageDriver from "unstorage/drivers/supabase-storage"; + +const storage = createStorage({ + driver: supabaseStorageDriver({ + url: "", + key: "", + bucket: "", + }), +}); +``` + +**Options:** + +- `base`: [optional] Prefix to use for all keys. Can be used for namespacing. +- `url`: The unique Supabase URL which is supplied when you create a new project in your project dashboard. +- `key`: The unique Supabase Key which is supplied when you create a new project in your project dashboard. +- `bucket`: The Supabase storage bucket name. diff --git a/package.json b/package.json index a47dd1b5..cea568c4 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,8 @@ "@types/jsdom": "^21.1.6", "@types/mri": "^1.1.5", "@types/node": "^20.11.5", + "@supabase/storage-js": "2.5.5", + "@supabase/supabase-js": "^2.39.7", "@upstash/redis": "^1.28.1", "@vercel/kv": "^0.2.4", "@vitejs/plugin-vue": "^5.0.3", @@ -106,6 +108,7 @@ "@capacitor/preferences": "^5.0.6", "@netlify/blobs": "^6.4.2", "@planetscale/database": "^1.13.0", + "@supabase/supabase-js": "^2.39.7", "@upstash/redis": "^1.28.1", "@vercel/kv": "^0.2.4", "idb-keyval": "^6.2.1" @@ -138,6 +141,9 @@ "@planetscale/database": { "optional": true }, + "@supabase/supabase-js": { + "optional": true + }, "@upstash/redis": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17b7920a..de97e1d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,12 @@ devDependencies: '@planetscale/database': specifier: ^1.13.0 version: 1.13.0 + '@supabase/storage-js': + specifier: 2.5.5 + version: 2.5.5 + '@supabase/supabase-js': + specifier: ^2.39.7 + version: 2.39.7 '@types/ioredis-mock': specifier: ^8.2.5 version: 8.2.5 @@ -1478,6 +1484,63 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@supabase/functions-js@2.1.5: + resolution: {integrity: sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: true + + /@supabase/gotrue-js@2.62.2: + resolution: {integrity: sha512-AP6e6W9rQXFTEJ7sTTNYQrNf0LCcnt1hUW+RIgUK+Uh3jbWvcIST7wAlYyNZiMlS9+PYyymWQ+Ykz/rOYSO0+A==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: true + + /@supabase/node-fetch@2.6.15: + resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==} + engines: {node: 4.x || >=6.0.0} + dependencies: + whatwg-url: 5.0.0 + dev: true + + /@supabase/postgrest-js@1.9.2: + resolution: {integrity: sha512-I6yHo8CC9cxhOo6DouDMy9uOfW7hjdsnCxZiaJuIVZm1dBGTFiQPgfMa9zXCamEWzNyWRjZvupAUuX+tqcl5Sw==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: true + + /@supabase/realtime-js@2.9.3: + resolution: {integrity: sha512-lAp50s2n3FhGJFq+wTSXLNIDPw5Y0Wxrgt44eM5nLSA3jZNUUP3Oq2Ccd1CbZdVntPCWLZvJaU//pAd2NE+QnQ==} + dependencies: + '@supabase/node-fetch': 2.6.15 + '@types/phoenix': 1.6.4 + '@types/ws': 8.5.10 + ws: 8.16.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + + /@supabase/storage-js@2.5.5: + resolution: {integrity: sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==} + dependencies: + '@supabase/node-fetch': 2.6.15 + dev: true + + /@supabase/supabase-js@2.39.7: + resolution: {integrity: sha512-1vxsX10Uhc2b+Dv9pRjBjHfqmw2N2h1PyTg9LEfICR3x2xwE24By1MGCjDZuzDKH5OeHCsf4it6K8KRluAAEXA==} + dependencies: + '@supabase/functions-js': 2.1.5 + '@supabase/gotrue-js': 2.62.2 + '@supabase/node-fetch': 2.6.15 + '@supabase/postgrest-js': 1.9.2 + '@supabase/realtime-js': 2.9.3 + '@supabase/storage-js': 2.5.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /@tootallnate/once@2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -1560,6 +1623,10 @@ packages: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true + /@types/phoenix@1.6.4: + resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==} + dev: true + /@types/resolve@1.20.2: resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} dev: true @@ -1609,6 +1676,12 @@ packages: '@types/webidl-conversions': 7.0.3 dev: true + /@types/ws@8.5.10: + resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} + dependencies: + '@types/node': 20.11.5 + dev: true + /@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.56.0)(typescript@5.3.3): resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} diff --git a/src/drivers/supabase-storage.ts b/src/drivers/supabase-storage.ts new file mode 100644 index 00000000..3a0743d9 --- /dev/null +++ b/src/drivers/supabase-storage.ts @@ -0,0 +1,135 @@ +import { SupabaseClient, createClient } from "@supabase/supabase-js"; +import { createError, defineDriver, joinKeys, normalizeKey } from "./utils"; + +export interface SupabaseOptions { + base?: string; + url?: string; + key?: string; + bucket?: string; +} + +const DRIVER_NAME = "supabase-storage"; + +export default defineDriver((opts: SupabaseOptions) => { + if (!opts.url) { + throw createError(DRIVER_NAME, "url"); + } + if (!opts.key) { + throw createError(DRIVER_NAME, "key"); + } + if (!opts.bucket) { + throw createError(DRIVER_NAME, "bucket"); + } + + const r = (key: string = "") => { + return (opts.base ? joinKeys(opts.base, key) : normalizeKey(key)).replace( + /:/g, + "/" + ); + }; + + let client: SupabaseClient; + + const getClient = () => { + if (!client) { + client = createClient(opts.url!, opts.key!); + } + return client; + }; + + const getKeys = async (prefix: string): Promise => { + const { data, error } = await getClient() + .storage.from(opts.bucket!) + .list(prefix); + + if (error) throw error; + if (!data) return []; + + const keys: string[] = []; + for (const { name, id } of data) { + const key = `${prefix !== "" ? prefix + "/" : ""}${name}`; + // If it's a folder, get the keys inside. The Supabase docs do not mention how to differentiate between a file and a folder, but it is observed that a folder has an id with a value of null. + if (!id) { + keys.push(...(await getKeys(key))); + } else { + keys.push(key); + } + } + return keys; + }; + + const getMeta = async (key: string) => { + const segments = r(key).split("/"); + const prefix = segments.slice(0, -1).join("/"); + const name = segments.at(-1); + const { data, error } = await getClient() + .storage.from(opts.bucket!) + .list(prefix, { + search: name, + }); + + if (error) throw error; + if (data.length === 0 || !data[0].id) return null; + return { + ...data[0], + }; + }; + + return { + name: DRIVER_NAME, + options: opts, + async hasItem(key) { + return getMeta(key).then(Boolean); + }, + async getItem(key) { + const { data, error } = await getClient() + .storage.from(opts.bucket!) + .download(r(key)); + + if (error) return null; + return await data.text(); + }, + async getItemRaw(key) { + const { data, error } = await getClient() + .storage.from(opts.bucket!) + .download(r(key)); + + if (error) return null; + return data.arrayBuffer(); + }, + async setItem(key, value) { + const { error } = await getClient() + .storage.from(opts.bucket!) + .upload(r(key), value, { + upsert: true, + }); + + if (error) throw error; + }, + async setItemRaw(key, value) { + const { error } = await getClient() + .storage.from(opts.bucket!) + .upload(r(key), value, { + upsert: true, + }); + + if (error) throw error; + }, + async removeItem(key) { + const { error } = await getClient() + .storage.from(opts.bucket!) + .remove([r(key)]); + + if (error) throw error; + }, + getMeta, + async getKeys(base) { + const keys = await getKeys(r(base)); + return opts.base ? keys.map((key) => key.slice(opts.base!.length)) : keys; + }, + async clear(base) { + const keys = await getKeys(r(base)); + await getClient().storage.from(opts.bucket!).remove(keys); + }, + }; +}); diff --git a/test/drivers/supabase-storage.test.ts b/test/drivers/supabase-storage.test.ts new file mode 100644 index 00000000..5c3a538e --- /dev/null +++ b/test/drivers/supabase-storage.test.ts @@ -0,0 +1,143 @@ +import { describe, vi } from "vitest"; +import driver from "../../src/drivers/supabase-storage"; +import { testDriver } from "./utils"; +import { createStorage } from "../../src"; +// The @supabase/storage-js package is included solely for the purpose of importing types. +import { FileObject, StorageClient, StorageError } from "@supabase/storage-js"; +import { joinKeys, normalizeKey } from "../../src/drivers/utils"; + +type StorageFileApi = ReturnType; + +const mockStorage = createStorage(); + +const mockFileObject = (isFile: boolean, name: string): FileObject => ({ + name: name, + id: isFile ? Math.random().toString() : "", + bucket_id: "", + owner: "", + updated_at: "", + created_at: "", + last_accessed_at: "", + metadata: {}, + buckets: { + id: "", + name: "", + owner: "", + created_at: "", + updated_at: "", + public: false, + }, +}); + +vi.mock("@supabase/supabase-js", () => { + const list: StorageFileApi["list"] = async (path, options) => { + try { + const allKeys = await mockStorage.getKeys(); + + // Find the exact file or folder + if (options?.search) { + const key = joinKeys(path || "", options.search || ""); + const data = allKeys.includes(key) + ? [mockFileObject(true, key)] + : allKeys.some((k) => k.startsWith(key)) + ? [mockFileObject(false, key)] + : []; + return { + data, + error: null, + }; + } + + // List all files and folders under the path + const key = normalizeKey(path || ""); + const keys = allKeys + .filter((k) => k.startsWith(key)) + .map((k) => normalizeKey(k.replace(key, ""))); + return { + data: keys.map((key) => { + const isFile = /^[^:]+$/.test(key); + const segments = key.split(":"); + return mockFileObject(isFile, segments[0]); + }), + error: null, + }; + } catch (error) { + throw error; + } + }; + + const upload: StorageFileApi["upload"] = async (path, fileBody) => { + try { + await mockStorage.setItemRaw(path, fileBody); + return { + data: { path }, + error: null, + }; + } catch (error) { + throw error; + } + }; + + const remove: StorageFileApi["remove"] = async (paths) => { + try { + await Promise.all(paths.map((p) => mockStorage.removeItem(p))); + return { + data: paths.map((path) => + mockFileObject(true, path.split("/").at(-1) || "") + ), + error: null, + }; + } catch (error) { + throw error; + } + }; + + const download: StorageFileApi["download"] = async (path) => { + try { + const file = await mockStorage.getItemRaw(path); + return file + ? { + data: new Blob([file]), + error: null, + } + : { + data: null, + error: new StorageError(""), + }; + } catch (error) { + throw error; + } + }; + + return { + createClient: () => ({ + storage: { + from: () => ({ + list, + upload, + remove, + download, + }), + }, + }), + }; +}); + +describe("drivers: supabase-storage", async () => { + testDriver({ + driver: driver({ + url: "", + key: "", + bucket: "", + }), + }); + + testDriver({ + driver: driver({ + base: "output", + url: "", + key: "", + bucket: "", + }), + }); +});