From e049ce65d31e7145b141c29f93f2ca0e638e158a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 18 Dec 2024 16:08:10 +0100 Subject: [PATCH] feat: add uploadthing driver (#390) Co-authored-by: Pooya Parsa --- .env.example | 2 + .github/workflows/ci.yml | 1 + docs/2.drivers/uploadthing.md | 38 +++++++++++ package.json | 7 ++- pnpm-lock.yaml | 100 +++++++++++++++++++++++++++++ src/_drivers.ts | 5 +- src/drivers/uploadthing.ts | 104 +++++++++++++++++++++++++++++++ test/drivers/uploadthing.test.ts | 14 +++++ 8 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 docs/2.drivers/uploadthing.md create mode 100644 src/drivers/uploadthing.ts create mode 100644 test/drivers/uploadthing.test.ts diff --git a/.env.example b/.env.example index b6193989..51e03c32 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,5 @@ VITE_VERCEL_BLOB_READ_WRITE_TOKEN= VITE_CLOUDFLARE_ACC_ID= VITE_CLOUDFLARE_KV_NS_ID= VITE_CLOUDFLARE_TOKEN= + +VITE_UPLOADTHING_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ba70092..826f07e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: VITE_CLOUDFLARE_ACC_ID: ${{ secrets.VITE_CLOUDFLARE_ACC_ID }} VITE_CLOUDFLARE_KV_NS_ID: ${{ secrets.VITE_CLOUDFLARE_KV_NS_ID }} VITE_CLOUDFLARE_TOKEN: ${{ secrets.VITE_CLOUDFLARE_TOKEN }} + VITE_UPLOADTHING_TOKEN: ${{ secrets.VITE_UPLOADTHING_TOKEN }} - uses: codecov/codecov-action@v5 - name: nightly release if: | diff --git a/docs/2.drivers/uploadthing.md b/docs/2.drivers/uploadthing.md new file mode 100644 index 00000000..fa2d6dec --- /dev/null +++ b/docs/2.drivers/uploadthing.md @@ -0,0 +1,38 @@ +--- +icon: qlementine-icons:cloud-16 +--- + +# UploadThing + +> Store data using UploadThing. + +::note{to="https://uploadthing.com/"} +Learn more about UploadThing. +:: + +::warning +UploadThing support is currently experimental! +
+There is a known issue that same key, if deleted cannot be used again [tracker issue](https://github.com/pingdotgg/uploadthing/issues/948). +:: + +## Usage + +To use, you will need to install `uploadthing` dependency in your project: + +:pm-install{name="uploadthing"} + +```js +import { createStorage } from "unstorage"; +import uploadthingDriver from "unstorage/drivers/uploadthing"; + +const storage = createStorage({ + driver: uploadthingDriver({ + // token: "", // UPLOADTHING_SECRET environment variable will be used if not provided. + }), +}); +``` + +**Options:** + +- `token`: Your UploadThing API key. Will be automatically inferred from the `UPLOADTHING_SECRET` environment variable if not provided. diff --git a/package.json b/package.json index fa30bc07..a502525a 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "types-cloudflare-worker": "^1.2.0", "typescript": "^5.7.2", "unbuild": "^3.0.1", + "uploadthing": "^7.4.1", "vite": "^6.0.3", "vitest": "^2.1.8", "wrangler": "^3.97.0" @@ -121,7 +122,8 @@ "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", - "ioredis": "^5.4.1" + "ioredis": "^5.4.1", + "uploadthing": "^7.4.1" }, "peerDependenciesMeta": { "@azure/app-configuration": { @@ -174,6 +176,9 @@ }, "ioredis": { "optional": true + }, + "uploadthing": { + "optional": true } }, "packageManager": "pnpm@9.15.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6b75336..5299574f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: unbuild: specifier: ^3.0.1 version: 3.0.1(typescript@5.7.2) + uploadthing: + specifier: ^7.4.1 + version: 7.4.1(express@4.21.2)(h3@1.13.0) vite: specifier: ^6.0.3 version: 6.0.3(@types/node@22.10.2)(jiti@2.4.1)(yaml@2.6.1) @@ -439,6 +442,11 @@ packages: '@deno/kv@0.8.4': resolution: {integrity: sha512-5q2izU1tp6wv8rDIwMb6GXe/B+aO/sjAjRAOIigEtX+qOiTLsPE++ibJbfafVb0LmjEdlA18Kpfo23fln73OtQ==} + '@effect/platform@0.70.7': + resolution: {integrity: sha512-TbNwj/mOJhycPygbmicGBS7CNtv5Z8WVheRbLUdP3oPAe/nbSOJVLc8ZPvOejhquF/1vJMKuqY5MWfkcBpvi/g==} + peerDependencies: + effect: ^3.11.5 + '@electric-sql/pglite@0.2.15': resolution: {integrity: sha512-Jiq31Dnk+rg8rMhcSxs4lQvHTyizNo5b269c1gCC3ldQ0sCLrNVPGzy+KnmonKy1ZArTUuXZf23/UamzFMKVaA==} @@ -1280,6 +1288,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.0.0-beta.3': + resolution: {integrity: sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw==} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -1391,6 +1402,12 @@ packages: resolution: {integrity: sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uploadthing/mime-types@0.3.2': + resolution: {integrity: sha512-WP/K75S/649lM0GUcd9jq4RjeTIc/0bO2UmLx4+usTSNy/x0K8gV0JdLWeUUbmTQtJoHd4ZTSvAdG7ZQgcmXvA==} + + '@uploadthing/shared@7.1.3': + resolution: {integrity: sha512-JlYz/JLZPrMAZRg7YPDUICodvIXLW9xS32aErqDvD76juq6nPRwFdwG5V089CY2kS5Ju9I0rGAu94hbJFqd9qQ==} + '@upstash/redis@1.34.3': resolution: {integrity: sha512-VT25TyODGy/8ljl7GADnJoMmtmJ1F8d84UXfGonRRF8fWYJz7+2J6GzW+a6ETGtk4OyuRTt7FRSvFG5GvrfSdQ==} @@ -2074,6 +2091,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.11.5: + resolution: {integrity: sha512-oSzaR/S/2A/qDTnDqMWxQUNSjCG2sRLB4NEvTu+l9RqE122MTgKXOWzw0x4MHsdovRTzAihfkpgBj2aLFnH2+w==} + electron-to-chromium@1.5.73: resolution: {integrity: sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg==} @@ -2270,6 +2290,10 @@ packages: resolution: {integrity: sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==} engines: {node: '>=18'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2335,6 +2359,9 @@ packages: resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} engines: {node: '>=8'} + find-my-way-ts@0.1.5: + resolution: {integrity: sha512-4GOTMrpGQVzsCH2ruUn2vmwzV/02zF4q+ybhCIrw/Rkt3L8KWcycdC6aJMctJzwN4fXD4SD5F/4B9Sksh5rE0A==} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3242,6 +3269,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multipasta@0.2.5: + resolution: {integrity: sha512-c8eMDb1WwZcE02WVjHoOmUVk7fnKU/RmUcosHACglrWAuPQsEJv+E8430sXj6jNc1jHw0zrS16aCjQh4BcEb4A==} + multistream@2.1.1: resolution: {integrity: sha512-xasv76hl6nr1dEy3lPvy7Ej7K/Lx3O/FCvwge8PeVJpciPPoNCbaANcNiBug3IpdvTveZUcAV0DJzdnUDMesNQ==} @@ -3732,6 +3762,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -4052,6 +4085,9 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sqids@0.3.0: + resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==} + sqlstring@2.3.3: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} @@ -4387,6 +4423,27 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uploadthing@7.4.1: + resolution: {integrity: sha512-an/ENGUJ06Rg9eI2/bWOUsm+6iDlufDRggUsw6HR4mqhNa/aqLHj5KrWP1CuVHP70fXMCsSM+BgVm00y5wt6wg==} + engines: {node: '>=18.13.0'} + peerDependencies: + express: '*' + fastify: '*' + h3: '*' + next: '*' + tailwindcss: '*' + peerDependenciesMeta: + express: + optional: true + fastify: + optional: true + h3: + optional: true + next: + optional: true + tailwindcss: + optional: true + uqr@0.1.2: resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} @@ -5096,6 +5153,12 @@ snapshots: '@deno/kv-linux-x64-gnu': 0.8.4 '@deno/kv-win32-x64-msvc': 0.8.4 + '@effect/platform@0.70.7(effect@3.11.5)': + dependencies: + effect: 3.11.5 + find-my-way-ts: 0.1.5 + multipasta: 0.2.5 + '@electric-sql/pglite@0.2.15': {} '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)': @@ -5660,6 +5723,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.28.1': optional: true + '@standard-schema/spec@1.0.0-beta.3': {} + '@trysound/sax@0.2.0': {} '@types/debug@4.1.12': @@ -5803,6 +5868,14 @@ snapshots: '@typescript-eslint/types': 8.18.0 eslint-visitor-keys: 4.2.0 + '@uploadthing/mime-types@0.3.2': {} + + '@uploadthing/shared@7.1.3': + dependencies: + '@uploadthing/mime-types': 0.3.2 + effect: 3.11.5 + sqids: 0.3.0 + '@upstash/redis@1.34.3': dependencies: crypto-js: 4.2.0 @@ -6574,6 +6647,10 @@ snapshots: ee-first@1.1.1: {} + effect@3.11.5: + dependencies: + fast-check: 3.23.2 + electron-to-chromium@1.5.73: {} emoji-regex@8.0.0: {} @@ -6941,6 +7018,10 @@ snapshots: fake-indexeddb@6.0.0: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -7014,6 +7095,8 @@ snapshots: make-dir: 3.1.0 pkg-dir: 4.2.0 + find-my-way-ts@0.1.5: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -7962,6 +8045,8 @@ snapshots: ms@2.1.3: {} + multipasta@0.2.5: {} + multistream@2.1.1: dependencies: inherits: 2.0.4 @@ -8432,6 +8517,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -8803,6 +8890,8 @@ snapshots: sprintf-js@1.1.3: {} + sqids@0.3.0: {} + sqlstring@2.3.3: {} stack-trace@0.0.10: {} @@ -9210,6 +9299,17 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uploadthing@7.4.1(express@4.21.2)(h3@1.13.0): + dependencies: + '@effect/platform': 0.70.7(effect@3.11.5) + '@standard-schema/spec': 1.0.0-beta.3 + '@uploadthing/mime-types': 0.3.2 + '@uploadthing/shared': 7.1.3 + effect: 3.11.5 + optionalDependencies: + express: 4.21.2 + h3: 1.13.0 + uqr@0.1.2: {} uri-js@4.4.1: diff --git a/src/_drivers.ts b/src/_drivers.ts index ada0fbb0..7780ba69 100644 --- a/src/_drivers.ts +++ b/src/_drivers.ts @@ -27,11 +27,12 @@ import type { PlanetscaleDriverOptions as PlanetscaleOptions } from "unstorage/d import type { RedisOptions as RedisOptions } from "unstorage/drivers/redis"; import type { S3DriverOptions as S3Options } from "unstorage/drivers/s3"; import type { SessionStorageOptions as SessionStorageOptions } from "unstorage/drivers/session-storage"; +import type { UploadThingOptions as UploadthingOptions } from "unstorage/drivers/uploadthing"; import type { UpstashOptions as UpstashOptions } from "unstorage/drivers/upstash"; import type { VercelBlobOptions as VercelBlobOptions } from "unstorage/drivers/vercel-blob"; import type { VercelKVOptions as VercelKVOptions } from "unstorage/drivers/vercel-kv"; -export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV"; +export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV"; export type BuiltinDriverOptions = { "azure-app-configuration": AzureAppConfigurationOptions; @@ -75,6 +76,7 @@ export type BuiltinDriverOptions = { "s3": S3Options; "session-storage": SessionStorageOptions; "sessionStorage": SessionStorageOptions; + "uploadthing": UploadthingOptions; "upstash": UpstashOptions; "vercel-blob": VercelBlobOptions; "vercelBlob": VercelBlobOptions; @@ -126,6 +128,7 @@ export const builtinDrivers = { "s3": "unstorage/drivers/s3", "session-storage": "unstorage/drivers/session-storage", "sessionStorage": "unstorage/drivers/session-storage", + "uploadthing": "unstorage/drivers/uploadthing", "upstash": "unstorage/drivers/upstash", "vercel-blob": "unstorage/drivers/vercel-blob", "vercelBlob": "unstorage/drivers/vercel-blob", diff --git a/src/drivers/uploadthing.ts b/src/drivers/uploadthing.ts new file mode 100644 index 00000000..8214ab91 --- /dev/null +++ b/src/drivers/uploadthing.ts @@ -0,0 +1,104 @@ +import { defineDriver, normalizeKey } from "./utils"; +import { UTApi } from "uploadthing/server"; + +// Reference: https://docs.uploadthing.com + +type UTApiOptions = Omit< + Exclude[0], undefined>, + "defaultKeyType" +>; + +type FileEsque = Parameters[0][0]; + +export interface UploadThingOptions extends UTApiOptions { + /** base key to add to keys */ + base?: string; +} + +const DRIVER_NAME = "uploadthing"; + +export default defineDriver((opts = {}) => { + let client: UTApi; + + const base = opts.base ? normalizeKey(opts.base) : ""; + const r = (key: string) => (base ? `${base}:${key}` : key); + + const getClient = () => { + return (client ??= new UTApi({ + ...opts, + defaultKeyType: "customId", + })); + }; + + const getKeys = async (base: string) => { + const client = getClient(); + const { files } = await client.listFiles({}); + return files + .map((file) => file.customId) + .filter((k) => k && k.startsWith(base)) as string[]; + }; + + const toFile = (key: string, value: BlobPart) => { + return Object.assign(new Blob([value]), { + name: key, + customId: key, + }) satisfies FileEsque; + }; + + return { + name: DRIVER_NAME, + getInstance() { + return getClient(); + }, + getKeys(base) { + return getKeys(r(base)); + }, + async hasItem(key) { + const client = getClient(); + const res = await client.getFileUrls(r(key)); + return res.data.length > 0; + }, + async getItem(key) { + const client = getClient(); + const url = await client + .getFileUrls(r(key)) + .then((res) => res.data[0]?.url); + if (!url) return null; + return fetch(url).then((res) => res.text()); + }, + async getItemRaw(key) { + const client = getClient(); + const url = await client + .getFileUrls(r(key)) + .then((res) => res.data[0]?.url); + if (!url) return null; + return fetch(url).then((res) => res.arrayBuffer()); + }, + async setItem(key, value) { + const client = getClient(); + await client.uploadFiles(toFile(r(key), value)); + }, + async setItemRaw(key, value) { + const client = getClient(); + await client.uploadFiles(toFile(r(key), value)); + }, + async setItems(items) { + const client = getClient(); + await client.uploadFiles( + items.map((item) => toFile(r(item.key), item.value)) + ); + }, + async removeItem(key) { + const client = getClient(); + await client.deleteFiles([r(key)]); + }, + async clear(base) { + const client = getClient(); + const keys = await getKeys(r(base)); + await client.deleteFiles(keys); + }, + // getMeta(key, opts) { + // // TODO: We don't currently have an endpoint to fetch metadata, but it does exist + // }, + }; +}); diff --git a/test/drivers/uploadthing.test.ts b/test/drivers/uploadthing.test.ts new file mode 100644 index 00000000..3fe3fa08 --- /dev/null +++ b/test/drivers/uploadthing.test.ts @@ -0,0 +1,14 @@ +import { describe } from "vitest"; +import driver from "../../src/drivers/uploadthing"; +import { testDriver } from "./utils"; + +const utfsToken = process.env.VITE_UPLOADTHING_TOKEN; + +describe.skipIf(!utfsToken)("drivers: uploadthing", { timeout: 30e3 }, () => { + process.env.UPLOADTHING_TOKEN = utfsToken; + testDriver({ + driver: driver({ + base: Math.round(Math.random() * 1_000_000).toString(16), + }), + }); +});