feat: add uploadthing driver (#390)

Co-authored-by: Pooya Parsa <pooya@pi0.io>
This commit is contained in:
Julius Marminge 2024-12-18 16:08:10 +01:00 committed by GitHub
parent ce9685e8f0
commit e049ce65d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 269 additions and 2 deletions

View File

@ -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=

View File

@ -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: |

View File

@ -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!
<br>
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: "<your 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.

View File

@ -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"

100
pnpm-lock.yaml generated
View File

@ -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:

View File

@ -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",

104
src/drivers/uploadthing.ts Normal file
View File

@ -0,0 +1,104 @@
import { defineDriver, normalizeKey } from "./utils";
import { UTApi } from "uploadthing/server";
// Reference: https://docs.uploadthing.com
type UTApiOptions = Omit<
Exclude<ConstructorParameters<typeof UTApi>[0], undefined>,
"defaultKeyType"
>;
type FileEsque = Parameters<UTApi["uploadFiles"]>[0][0];
export interface UploadThingOptions extends UTApiOptions {
/** base key to add to keys */
base?: string;
}
const DRIVER_NAME = "uploadthing";
export default defineDriver<UploadThingOptions, UTApi>((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]), <FileEsque>{
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
// },
};
});

View File

@ -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),
}),
});
});