├── .github ├── CODEOWNERS ├── codecov.yml └── workflows │ ├── autofix.yml │ └── ci.yml ├── .npmrc ├── docs ├── .npmrc ├── .gitignore ├── pnpm-workspace.yaml ├── package.json ├── 2.drivers │ ├── null.md │ ├── memory.md │ ├── lru-cache.md │ ├── overlay.md │ ├── capacitor-preferences.md │ ├── uploadthing.md │ ├── github.md │ ├── http.md │ ├── fs.md │ ├── mongodb.md │ ├── database.md │ ├── upstash.md │ ├── s3.md │ ├── planetscale.md │ ├── browser.md │ ├── redis.md │ ├── deno.md │ ├── 0.index.md │ ├── netlify.md │ └── vercel.md ├── 1.guide │ ├── 2.utils.md │ ├── 4.custom-driver.md │ └── 3.http-server.md ├── .docs │ └── public │ │ └── icon.svg └── .config │ └── docs.yaml ├── .prettierrc ├── vercel.json ├── renovate.json ├── test ├── test.png ├── drivers │ ├── memory.test.ts │ ├── azure-cosmos.test.ts │ ├── overlay.test.ts │ ├── vercel-kv.test.ts │ ├── deno-kv-node.test.ts │ ├── azure-app-configuration.test.ts │ ├── azure-key-vault.test.ts │ ├── deno-kv.fixture.ts │ ├── null.test.ts │ ├── uploadthing.test.ts │ ├── upstash.test.ts │ ├── vercel-blob.test.ts │ ├── cloudflare-kv-http.test.ts │ ├── azure-storage-table.test.ts │ ├── lru-cache.test.ts │ ├── capacitor-preferences.test.ts │ ├── github.test.ts │ ├── deno-kv.test.ts │ ├── cloudflare-kv-binding.test.ts │ ├── redis.test.ts │ ├── netlify-blobs.test.ts │ ├── s3.test.ts │ ├── mongodb.test.ts │ ├── indexedb.test.ts │ ├── http.test.ts │ ├── cloudflare-r2-binding.test.ts │ ├── vercel-runtime-cache.test.ts │ ├── db0.test.ts │ ├── session-storage.test.ts │ ├── localstorage.test.ts │ ├── fs-lite.test.ts │ ├── fs.test.ts │ └── azure-storage-blob.test.ts ├── server.bench.ts ├── storage.test-d.ts └── server.test.ts ├── .prettierignore ├── pnpm-workspace.yaml ├── wrangler.toml ├── .gitignore ├── .env.example ├── .editorconfig ├── src ├── index.ts ├── drivers │ ├── session-storage.ts │ ├── null.ts │ ├── deno-kv-node.ts │ ├── utils │ │ ├── path.ts │ │ ├── cloudflare.ts │ │ ├── index.ts │ │ └── node-fs.ts │ ├── memory.ts │ ├── indexedb.ts │ ├── capacitor-preferences.ts │ ├── lru-cache.ts │ ├── fs-lite.ts │ ├── overlay.ts │ ├── cloudflare-kv-binding.ts │ ├── localstorage.ts │ ├── upstash.ts │ ├── uploadthing.ts │ ├── planetscale.ts │ ├── vercel-runtime-cache.ts │ ├── cloudflare-r2-binding.ts │ ├── http.ts │ ├── mongodb.ts │ ├── vercel-kv.ts │ ├── deno-kv.ts │ ├── netlify-blobs.ts │ ├── redis.ts │ ├── fs.ts │ ├── github.ts │ ├── azure-cosmos.ts │ ├── azure-key-vault.ts │ ├── vercel-blob.ts │ └── azure-app-configuration.ts ├── _utils.ts └── utils.ts ├── vite.config.mjs ├── eslint.config.mjs ├── tsconfig.json ├── LICENSE ├── scripts └── gen-drivers.ts └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pi0 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /docs/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nuxt 3 | .data 4 | .output 5 | dist 6 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /test/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unjs/unstorage/HEAD/test/test.png -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 5% 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | test/drivers/tmp 2 | src/_drivers.ts 3 | dist 4 | node_modules 5 | .output 6 | .nuxt 7 | CHANGELOG.md 8 | pnpm-lock.yaml 9 | docs/2.drivers/0.index.md 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: [] 2 | ignoredBuiltDependencies: 3 | - "@parcel/watcher" 4 | - "@tailwindcss/oxide" 5 | - esbuild 6 | - vue-demi 7 | onlyBuiltDependencies: 8 | - better-sqlite3 9 | -------------------------------------------------------------------------------- /docs/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: [] 2 | ignoredBuiltDependencies: 3 | - "@parcel/watcher" 4 | - "@tailwindcss/oxide" 5 | - esbuild 6 | - vue-demi 7 | onlyBuiltDependencies: 8 | - better-sqlite3 9 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2024-12-01" 2 | 3 | kv_namespaces = [ 4 | { binding = "STORAGE", id = "" } 5 | ] 6 | 7 | r2_buckets = [ 8 | { binding = "BUCKET", bucket_name = "default" }, 9 | ] 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | *.log 4 | .DS_Store 5 | coverage 6 | dist 7 | tmp 8 | /drivers 9 | /test.* 10 | __* 11 | .vercel 12 | .netlify 13 | test/fs-storage/** 14 | .env 15 | .wrangler 16 | tsconfig.tsbuildinfo 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_UPSTASH_REDIS_REST_URL= 2 | VITE_UPSTASH_REDIS_REST_TOKEN= 3 | 4 | VITE_VERCEL_BLOB_READ_WRITE_TOKEN= 5 | 6 | VITE_CLOUDFLARE_ACC_ID= 7 | VITE_CLOUDFLARE_KV_NS_ID= 8 | VITE_CLOUDFLARE_TOKEN= 9 | 10 | VITE_UPLOADTHING_TOKEN= 11 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "build": "undocs build", 6 | "dev": "undocs dev" 7 | }, 8 | "devDependencies": { 9 | "undocs": "^0.4.10" 10 | }, 11 | "packageManager": "pnpm@10.20.0" 12 | } 13 | -------------------------------------------------------------------------------- /test/drivers/memory.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import driver from "../../src/drivers/memory.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | describe("drivers: memory", () => { 6 | testDriver({ 7 | driver: driver(), 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./storage.ts"; 2 | export * from "./types.ts"; 3 | export * from "./utils.ts"; 4 | 5 | export { defineDriver } from "./drivers/utils/index.ts"; 6 | 7 | export { 8 | builtinDrivers, 9 | type BuiltinDriverName, 10 | type BuiltinDriverOptions, 11 | } from "./_drivers.ts"; 12 | -------------------------------------------------------------------------------- /test/drivers/azure-cosmos.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import driver from "../../src/drivers/azure-cosmos.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | describe.skip("drivers: azure-cosmos", () => { 6 | testDriver({ 7 | driver: driver({ 8 | endpoint: "COSMOS_DB_ENDPOINT", 9 | accountKey: "COSMOS_DB_KEY", 10 | }), 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/drivers/overlay.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import driver from "../../src/drivers/overlay.ts"; 3 | import memory from "../../src/drivers/memory.ts"; 4 | import { testDriver } from "./utils.ts"; 5 | 6 | describe("drivers: overlay", () => { 7 | const [s1, s2] = [memory(), memory()]; 8 | testDriver({ 9 | driver: driver({ 10 | layers: [s1, s2], 11 | }), 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/drivers/vercel-kv.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import vercelKVDriver from "../../src/drivers/vercel-kv.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | const hasEnv = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN; 6 | 7 | describe.skipIf(!hasEnv)("drivers: vercel-kv", async () => { 8 | testDriver({ 9 | driver: () => vercelKVDriver({}), 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/drivers/deno-kv-node.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import denoKvNodeDriver from "../../src/drivers/deno-kv-node.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | describe("drivers: deno-kv-node", async () => { 6 | testDriver({ 7 | driver: denoKvNodeDriver({ 8 | path: ":memory:", 9 | base: Math.round(Math.random() * 1_000_000).toString(16), 10 | }), 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/drivers/azure-app-configuration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import driver from "../../src/drivers/azure-app-configuration.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | describe.skip("drivers: azure-app-configuration", () => { 6 | testDriver({ 7 | driver: driver({ 8 | appConfigName: "unstoragetest", 9 | label: "dev", 10 | prefix: "app01", 11 | }), 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/drivers/azure-key-vault.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import driver from "../../src/drivers/azure-key-vault.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | describe.skip("drivers: azure-key-vault", { timeout: 80_000 }, () => { 6 | testDriver({ 7 | driver: driver({ vaultName: "testunstoragevault" }), 8 | }); 9 | }); // 60s as the Azure Key Vault need to delete and purge the secret before it can be created again. 10 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, configDefaults } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | testTimeout: 10_000, 6 | retry: process.env.CI ? 2 : undefined, 7 | typecheck: { 8 | enabled: true, 9 | }, 10 | coverage: { 11 | exclude: [ 12 | ...configDefaults.coverage.exclude, 13 | "./drivers/**", 14 | "./scripts/**", 15 | ], 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /test/drivers/deno-kv.fixture.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "srvx"; 2 | import { createStorage } from "../../src/index.ts"; 3 | import denoKV from "../../src/drivers/deno-kv.ts"; 4 | import { createStorageHandler } from "../../src/server.ts"; 5 | 6 | const storage = createStorage({ 7 | driver: denoKV({ 8 | path: ":memory:", 9 | base: Math.round(Math.random() * 1_000_000).toString(16), 10 | }), 11 | }); 12 | 13 | serve({ fetch: createStorageHandler(storage) }); 14 | -------------------------------------------------------------------------------- /test/drivers/null.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import driver from "../../src/drivers/null.ts"; 3 | import { createStorage } from "../../src/index.ts"; 4 | 5 | describe("drivers: null", async () => { 6 | const storage = createStorage({ driver: driver() }); 7 | it("setItem", async () => { 8 | await storage.setItem("key", "value"); 9 | }); 10 | it("getItem", async () => { 11 | expect(await storage.getItem("key")).toEqual(null); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/drivers/uploadthing.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import driver from "../../src/drivers/uploadthing.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | const utfsToken = process.env.VITE_UPLOADTHING_TOKEN; 6 | 7 | describe.skipIf(!utfsToken)("drivers: uploadthing", { timeout: 30e3 }, () => { 8 | process.env.UPLOADTHING_TOKEN = utfsToken; 9 | testDriver({ 10 | driver: driver({ 11 | base: Math.round(Math.random() * 1_000_000).toString(16), 12 | }), 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/drivers/session-storage.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver } from "./utils/index.ts"; 2 | import localstorage, { type LocalStorageOptions } from "./localstorage.ts"; 3 | 4 | export interface SessionStorageOptions extends LocalStorageOptions {} 5 | 6 | const DRIVER_NAME = "session-storage"; 7 | 8 | export default defineDriver((opts: SessionStorageOptions = {}) => { 9 | return { 10 | ...localstorage({ 11 | windowKey: "sessionStorage", 12 | ...opts, 13 | }), 14 | name: DRIVER_NAME, 15 | }; 16 | }); 17 | -------------------------------------------------------------------------------- /docs/2.drivers/null.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: bi:trash3-fill 3 | --- 4 | 5 | # Null 6 | 7 | > Discards all data. 8 | 9 | ::warning 10 | This driver does NOT store any data. It will discard any data written to it and will always return null similar to [`/dev/null`](https://en.wikipedia.org/wiki/Null_device) 11 | :: 12 | 13 | ## Usage 14 | 15 | **Driver name:** `null` 16 | 17 | ```js 18 | import { createStorage } from "unstorage"; 19 | import nullDriver from "unstorage/drivers/null"; 20 | 21 | const storage = createStorage({ 22 | driver: nullDriver(), 23 | }); 24 | ``` 25 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci 2 | on: { push: {}, pull_request: {} } 3 | permissions: { contents: read } 4 | jobs: 5 | autofix: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v6 9 | - run: npm i -fg corepack && corepack enable 10 | - uses: actions/setup-node@v6 11 | with: { node-version: lts/*, cache: pnpm } 12 | - run: pnpm install 13 | - run: pnpm lint:fix 14 | - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 15 | with: { commit-message: "chore: apply automated updates" } 16 | -------------------------------------------------------------------------------- /test/drivers/upstash.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { testDriver } from "./utils.ts"; 3 | import upstashDriver from "../../src/drivers/upstash.ts"; 4 | 5 | const url = process.env.VITE_UPSTASH_REDIS_REST_URL; 6 | const token = process.env.VITE_UPSTASH_REDIS_REST_TOKEN; 7 | 8 | describe.skipIf(!url || !token)("drivers: upstash", async () => { 9 | process.env.UPSTASH_REDIS_REST_URL = url; 10 | process.env.UPSTASH_REDIS_REST_TOKEN = token; 11 | testDriver({ 12 | driver: upstashDriver({ 13 | base: Math.round(Math.random() * 1_000_000).toString(16), 14 | }), 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/drivers/vercel-blob.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { testDriver } from "./utils.ts"; 3 | import vercelBlobDriver from "../../src/drivers/vercel-blob.ts"; 4 | 5 | const token = process.env.VITE_VERCEL_BLOB_READ_WRITE_TOKEN; 6 | 7 | describe.skipIf(!token)("drivers: vercel-blob", async () => { 8 | process.env.VERCEL_TEST_READ_WRITE_TOKEN = token; 9 | testDriver({ 10 | driver: () => 11 | vercelBlobDriver({ 12 | access: "public", 13 | base: Math.round(Math.random() * 1_000_000).toString(16), 14 | envPrefix: "VERCEL_TEST", 15 | }), 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /docs/2.drivers/memory.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: bi:memory 3 | --- 4 | 5 | # Memory 6 | 7 | > Keep data in memory. 8 | 9 | Keeps data in memory using [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map). (default storage) 10 | 11 | ## Usage 12 | 13 | **Driver name:** `memory` 14 | 15 | ::note 16 | By default, it is mounted at the top level, so it's unlikely that you will need to mount it again. 17 | :: 18 | 19 | ```js 20 | import { createStorage } from "unstorage"; 21 | import memoryDriver from "unstorage/drivers/memory"; 22 | 23 | const storage = createStorage({ 24 | driver: memoryDriver(), 25 | }); 26 | ``` 27 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unjs from "eslint-config-unjs"; 2 | 3 | export default unjs({ 4 | ignores: ["drivers", "/server*", "docs/.*"], 5 | rules: { 6 | "unicorn/no-null": 0, 7 | "unicorn/prevent-abbreviations": 0, 8 | "@typescript-eslint/no-non-null-assertion": 0, 9 | "unicorn/prefer-string-replace-all": 0, 10 | "unicorn/prefer-at": 0, 11 | "unicorn/catch-error-name": 0, 12 | "unicorn/prefer-logical-operator-over-ternary": 0, 13 | "unicorn/prefer-ternary": 0, 14 | "unicorn/prefer-string-raw": 0, 15 | "@typescript-eslint/no-empty-object-type": 0, 16 | "unicorn/prefer-global-this": 0, // window. usage 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/drivers/null.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver } from "./utils/index.ts"; 2 | 3 | const DRIVER_NAME = "null"; 4 | 5 | export default defineDriver(() => { 6 | return { 7 | name: DRIVER_NAME, 8 | hasItem() { 9 | return false; 10 | }, 11 | getItem() { 12 | return null; 13 | }, 14 | getItemRaw() { 15 | return null; 16 | }, 17 | getItems() { 18 | return []; 19 | }, 20 | getMeta() { 21 | return null; 22 | }, 23 | getKeys() { 24 | return []; 25 | }, 26 | setItem() {}, 27 | setItemRaw() {}, 28 | setItems() {}, 29 | removeItem() {}, 30 | clear() {}, 31 | }; 32 | }); 33 | -------------------------------------------------------------------------------- /test/drivers/cloudflare-kv-http.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import cfKvHttpDriver from "../../src/drivers/cloudflare-kv-http.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | const accountId = process.env.VITE_CLOUDFLARE_ACC_ID; 6 | const namespaceId = process.env.VITE_CLOUDFLARE_KV_NS_ID; 7 | const apiToken = process.env.VITE_CLOUDFLARE_TOKEN; 8 | 9 | describe.skipIf(!accountId || !namespaceId || !apiToken)( 10 | "drivers: cloudflare-kv-http", 11 | () => { 12 | testDriver({ 13 | driver: () => 14 | cfKvHttpDriver({ 15 | accountId: accountId!, 16 | namespaceId: namespaceId!, 17 | apiToken: apiToken!, 18 | base: Math.round(Math.random() * 1_000_000).toString(16), 19 | }), 20 | }); 21 | } 22 | ); 23 | -------------------------------------------------------------------------------- /src/drivers/deno-kv-node.ts: -------------------------------------------------------------------------------- 1 | import { openKv, type Kv } from "@deno/kv"; 2 | import { defineDriver } from "./utils/index.ts"; 3 | import denoKV from "./deno-kv.ts"; 4 | 5 | // https://docs.deno.com/deploy/kv/manual/node/ 6 | 7 | export interface DenoKvNodeOptions { 8 | base?: string; 9 | path?: string; 10 | openKvOptions?: Parameters[1]; 11 | } 12 | 13 | const DRIVER_NAME = "deno-kv-node"; 14 | 15 | export default defineDriver>( 16 | (opts: DenoKvNodeOptions = {}) => { 17 | const baseDriver = denoKV({ 18 | ...opts, 19 | openKv: () => openKv(opts.path, opts.openKvOptions), 20 | }); 21 | return { 22 | ...baseDriver, 23 | getInstance() { 24 | return baseDriver.getInstance!() as Promise; 25 | }, 26 | name: DRIVER_NAME, 27 | }; 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /docs/2.drivers/lru-cache.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: material-symbols:cached-rounded 3 | --- 4 | 5 | # LRU Cache 6 | 7 | > Keeps cached data in memory using LRU Cache. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `lru-cache` 12 | 13 | Keeps cached data in memory using [LRU Cache](https://www.npmjs.com/package/lru-cache). 14 | 15 | See [`lru-cache`](https://www.npmjs.com/package/lru-cache) for supported options. 16 | 17 | By default, [`max`](https://www.npmjs.com/package/lru-cache#max) setting is set to `1000` items. 18 | 19 | A default behavior for [`sizeCalculation`](https://www.npmjs.com/package/lru-cache#sizecalculation) option is implemented based on buffer size of both key and value. 20 | 21 | ```js 22 | import { createStorage } from "unstorage"; 23 | import lruCacheDriver from "unstorage/drivers/lru-cache"; 24 | 25 | const storage = createStorage({ 26 | driver: lruCacheDriver(), 27 | }); 28 | ``` 29 | -------------------------------------------------------------------------------- /src/drivers/utils/path.ts: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/h3js/h3/blob/main/src/utils/internal/path.ts 2 | 3 | export function joinURL( 4 | base: string | undefined, 5 | path: string | undefined 6 | ): string { 7 | if (!base || base === "/") { 8 | return path || "/"; 9 | } 10 | if (!path || path === "/") { 11 | return base || "/"; 12 | } 13 | 14 | const baseHasTrailing = base[base.length - 1] === "/"; 15 | const pathHasLeading = path[0] === "/"; 16 | if (baseHasTrailing && pathHasLeading) { 17 | return base + path.slice(1); 18 | } 19 | if (!baseHasTrailing && !pathHasLeading) { 20 | return base + "/" + path; 21 | } 22 | return base + path; 23 | } 24 | 25 | export function withTrailingSlash(path: string | undefined): string { 26 | if (!path || path === "/") { 27 | return "/"; 28 | } 29 | return path[path.length - 1] === "/" ? path : `${path}/`; 30 | } 31 | -------------------------------------------------------------------------------- /docs/2.drivers/overlay.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: carbon:overlay 3 | --- 4 | 5 | # Overlay 6 | 7 | This is a special driver that creates a multi-layer overlay driver. 8 | 9 | All write operations happen on the top level layer while values are read from all layers. 10 | 11 | When removing a key, a special value `__OVERLAY_REMOVED__` will be set on the top level layer internally. 12 | 13 | ## Usage 14 | 15 | **Driver name:** `overlay` 16 | 17 | In the example below, we create an in-memory overlay on top of fs. No changes will be actually written to the disk when setting new keys. 18 | 19 | ```js 20 | import { createStorage } from "unstorage"; 21 | import overlay from "unstorage/drivers/overlay"; 22 | import memory from "unstorage/drivers/memory"; 23 | import fs from "unstorage/drivers/fs"; 24 | 25 | const storage = createStorage({ 26 | driver: overlay({ 27 | layers: [memory(), fs({ base: "./data" })], 28 | }), 29 | }); 30 | ``` 31 | -------------------------------------------------------------------------------- /src/drivers/memory.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver } from "./utils/index.ts"; 2 | 3 | const DRIVER_NAME = "memory"; 4 | 5 | export default defineDriver>(() => { 6 | const data = new Map(); 7 | 8 | return { 9 | name: DRIVER_NAME, 10 | getInstance: () => data, 11 | hasItem(key) { 12 | return data.has(key); 13 | }, 14 | getItem(key) { 15 | return data.get(key) ?? null; 16 | }, 17 | getItemRaw(key) { 18 | return data.get(key) ?? null; 19 | }, 20 | setItem(key, value) { 21 | data.set(key, value); 22 | }, 23 | setItemRaw(key, value) { 24 | data.set(key, value); 25 | }, 26 | removeItem(key) { 27 | data.delete(key); 28 | }, 29 | getKeys() { 30 | return [...data.keys()]; 31 | }, 32 | clear() { 33 | data.clear(); 34 | }, 35 | dispose() { 36 | data.clear(); 37 | }, 38 | }; 39 | }); 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "moduleDetection": "force", 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "isolatedModules": false, // TODO 12 | "verbatimModuleSyntax": true, 13 | "erasableSyntaxOnly": true, 14 | "noUncheckedIndexedAccess": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitAny": false, // TODO 17 | "noImplicitOverride": true, 18 | "noEmit": true, 19 | "skipLibCheck": true, 20 | "composite": true, 21 | // "isolatedDeclarations": true, 22 | "allowImportingTsExtensions": true, 23 | "types": ["node", "deno"], 24 | "paths": { 25 | "unstorage/drivers/*": ["./src/drivers/*.ts"] 26 | } 27 | }, 28 | "include": ["src", "test"] 29 | } 30 | -------------------------------------------------------------------------------- /test/drivers/azure-storage-table.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, afterAll } from "vitest"; 2 | import driver from "../../src/drivers/azure-storage-table.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import { TableClient } from "@azure/data-tables"; 5 | import { ChildProcess, exec } from "node:child_process"; 6 | 7 | describe.skip("drivers: azure-storage-table", () => { 8 | let azuriteProcess: ChildProcess; 9 | 10 | beforeAll(async () => { 11 | azuriteProcess = exec("npx azurite-table --silent"); 12 | const client = TableClient.fromConnectionString( 13 | "UseDevelopmentStorage=true", 14 | "unstorage" 15 | ); 16 | await client.createTable(); 17 | }); 18 | 19 | afterAll(() => { 20 | azuriteProcess.kill(9); 21 | }); 22 | 23 | testDriver({ 24 | driver: driver({ 25 | connectionString: "UseDevelopmentStorage=true", 26 | accountName: "local", 27 | }), 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/drivers/lru-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from "vitest"; 2 | import driver from "../../src/drivers/lru-cache.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | describe("drivers: lru-cache", () => { 6 | testDriver({ 7 | driver: driver({}), 8 | }); 9 | }); 10 | 11 | describe("drivers: lru-cache with size", () => { 12 | testDriver({ 13 | driver: driver({ 14 | maxEntrySize: 50, 15 | }), 16 | additionalTests(ctx) { 17 | it("should not store large items", async () => { 18 | await ctx.storage.setItem( 19 | "big", 20 | "0123456789012345678901234567890123456789012345678901234567890123456789" 21 | ); 22 | expect(await ctx.storage.getItem("big")).toBe(null); 23 | 24 | await ctx.storage.setItemRaw("bigBuff", Buffer.alloc(100)); 25 | expect(await ctx.storage.getItemRaw("bigBuff")).toBe(null); 26 | }); 27 | }, 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /docs/2.drivers/capacitor-preferences.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: nonicons:capacitor-16 3 | --- 4 | 5 | # Capacitor Preferences 6 | 7 | > Store data via Capacitor Preferences API on mobile devices or local storage on the web. 8 | 9 | ::read-more{to="https://capacitorjs.com/docs/apis/preferences"} 10 | Learn more about Capacitor Preferences API. 11 | :: 12 | 13 | ## Usage 14 | 15 | **Driver name:** `capacitor-preferences` 16 | 17 | To use this driver, you need to install and sync `@capacitor/preferences` inside your capacitor project: 18 | 19 | :pm-install{name="@capacitor/preferences"} 20 | :pm-x{command="cap sync"} 21 | 22 | Usage: 23 | 24 | ```js 25 | import { createStorage } from "unstorage"; 26 | import capacitorPreferences from "unstorage/drivers/capacitor-preferences"; 27 | 28 | const storage = createStorage({ 29 | driver: capacitorPreferences({ 30 | base: "test", 31 | }), 32 | }); 33 | ``` 34 | 35 | **Options:** 36 | 37 | - `base`: Add `${base}:` to all keys to avoid collision 38 | -------------------------------------------------------------------------------- /test/server.bench.ts: -------------------------------------------------------------------------------- 1 | import { bench, run } from "mitata"; 2 | import { serve } from "srvx"; 3 | import { $fetch } from "ofetch"; 4 | import { createStorage } from "../src/index.ts"; 5 | import { createStorageHandler } from "../src/server.ts"; 6 | 7 | async function main() { 8 | const storage = createStorage(); 9 | 10 | for (let i = 0; i < 10; i++) { 11 | for (let j = 0; j < 10; j++) { 12 | await storage.set(`key:${i}:${j}`, `value-${i}-${j}`); 13 | } 14 | } 15 | 16 | const storageServer = createStorageHandler(storage, {}); 17 | 18 | const server = await serve({ 19 | fetch: storageServer, 20 | port: 0, 21 | }); 22 | 23 | const fetchStorage = (url: string, options?: any) => 24 | $fetch(url, { baseURL: server.url, ...options }); 25 | 26 | bench("storage server", async () => { 27 | await Promise.all([fetchStorage(`/key:`), fetchStorage(`/key:0:0`)]); 28 | }); 29 | 30 | await run(); 31 | 32 | await server.close(); 33 | } 34 | 35 | // eslint-disable-next-line unicorn/prefer-top-level-await 36 | main(); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Pooya Parsa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/2.drivers/uploadthing.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: qlementine-icons:cloud-16 3 | --- 4 | 5 | # UploadThing 6 | 7 | > Store data using UploadThing. 8 | 9 | ::note{to="https://uploadthing.com/"} 10 | Learn more about UploadThing. 11 | :: 12 | 13 | ::warning 14 | UploadThing support is currently experimental! 15 |
16 | There is a known issue that same key, if deleted cannot be used again [tracker issue](https://github.com/pingdotgg/uploadthing/issues/948). 17 | :: 18 | 19 | ## Usage 20 | 21 | **Driver name:** `uploadthing` 22 | 23 | To use, you will need to install `uploadthing` dependency in your project: 24 | 25 | :pm-install{name="uploadthing"} 26 | 27 | ```js 28 | import { createStorage } from "unstorage"; 29 | import uploadthingDriver from "unstorage/drivers/uploadthing"; 30 | 31 | const storage = createStorage({ 32 | driver: uploadthingDriver({ 33 | // token: "", // UPLOADTHING_SECRET environment variable will be used if not provided. 34 | }), 35 | }); 36 | ``` 37 | 38 | **Options:** 39 | 40 | - `token`: Your UploadThing API key. Will be automatically inferred from the `UPLOADTHING_SECRET` environment variable if not provided. 41 | -------------------------------------------------------------------------------- /test/drivers/capacitor-preferences.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, vi } from "vitest"; 2 | import driver from "../../src/drivers/capacitor-preferences.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import { afterEach } from "node:test"; 5 | 6 | vi.mock("@capacitor/preferences", () => { 7 | const data = new Map(); 8 | 9 | const keys = vi.fn(() => Promise.resolve({ keys: [...data.keys()] })); 10 | const get = vi.fn(({ key }) => 11 | Promise.resolve({ value: data.get(key) ?? null }) 12 | ); 13 | const set = vi.fn(({ key, value }) => Promise.resolve(data.set(key, value))); 14 | const remove = vi.fn(({ key }) => Promise.resolve(data.delete(key))); 15 | const clear = vi.fn(() => Promise.resolve(data.clear())); 16 | 17 | return { 18 | Preferences: { 19 | keys, 20 | get, 21 | set, 22 | remove, 23 | clear, 24 | }, 25 | }; 26 | }); 27 | 28 | describe("drivers: capacitor-preferences", () => { 29 | afterEach(() => { 30 | vi.resetAllMocks(); 31 | }); 32 | 33 | testDriver({ 34 | driver: driver({}), 35 | }); 36 | 37 | testDriver({ 38 | driver: driver({ base: "test" }), 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /docs/2.drivers/github.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: mdi:github 3 | --- 4 | 5 | # GitHub 6 | 7 | > Map files from a remote GitHub repository (readonly). 8 | 9 | ## Usage 10 | 11 | **Driver name:** `github` 12 | 13 | This driver fetches all possible keys once and keep it in cache for 10 minutes. Due to GitHub rate limit, it is highly recommended to provide a token. It only applies to fetching keys. 14 | 15 | ```js 16 | import { createStorage } from "unstorage"; 17 | import githubDriver from "unstorage/drivers/github"; 18 | 19 | const storage = createStorage({ 20 | driver: githubDriver({ 21 | repo: "nuxt/nuxt", 22 | branch: "main", 23 | dir: "/docs", 24 | }), 25 | }); 26 | ``` 27 | 28 | **Options:** 29 | 30 | - `repo`: GitHub repository. Format is `username/repo` or `org/repo` **(required)** 31 | - `token`: GitHub API token. **(recommended)** 32 | - `branch`: Target branch. Default is `main` 33 | - `dir`: Use a directory as driver root. 34 | - `ttl`: Filenames cache revalidate time. Default is `600` seconds (10 minutes) 35 | - `apiURL`: GitHub API domain. Default is `https://api.github.com` 36 | - `cdnURL`: GitHub RAW CDN Url. Default is `https://raw.githubusercontent.com` 37 | -------------------------------------------------------------------------------- /src/drivers/utils/cloudflare.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { createError } from "./index.ts"; 3 | 4 | export function getBinding(binding: KVNamespace | R2Bucket | string) { 5 | let bindingName = "[binding]"; 6 | 7 | if (typeof binding === "string") { 8 | bindingName = binding; 9 | binding = ((globalThis as any)[bindingName] || 10 | (globalThis as any).__env__?.[bindingName]) as KVNamespace | R2Bucket; 11 | } 12 | 13 | if (!binding) { 14 | throw createError( 15 | "cloudflare", 16 | `Invalid binding \`${bindingName}\`: \`${binding}\`` 17 | ); 18 | } 19 | 20 | for (const key of ["get", "put", "delete"]) { 21 | if (!(key in binding)) { 22 | throw createError( 23 | "cloudflare", 24 | `Invalid binding \`${bindingName}\`: \`${key}\` key is missing` 25 | ); 26 | } 27 | } 28 | 29 | return binding; 30 | } 31 | 32 | export function getKVBinding(binding: KVNamespace | string = "STORAGE") { 33 | return getBinding(binding) as KVNamespace; 34 | } 35 | 36 | export function getR2Binding(binding: R2Bucket | string = "BUCKET") { 37 | return getBinding(binding) as R2Bucket; 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: { branches: [main] } 5 | pull_request: { branches: [main] } 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - run: npm i -g --force corepack && corepack enable 13 | - uses: actions/setup-node@v6 14 | with: { node-version: lts/*, cache: pnpm } 15 | - run: pnpm install 16 | - run: pnpm lint 17 | - run: pnpm test:types 18 | - run: pnpm build 19 | - run: pnpm vitest --coverage 20 | - uses: codecov/codecov-action@v5 21 | with: { token: "${{ secrets.CODECOV_TOKEN }}" } 22 | publish: 23 | runs-on: ubuntu-latest 24 | permissions: { id-token: write, contents: read } 25 | needs: tests 26 | if: contains('refs/heads/main', github.ref) && github.event_name == 'push' 27 | steps: 28 | - uses: actions/checkout@v6 29 | with: { fetch-depth: 0 } 30 | - run: npm i -fg corepack && corepack enable 31 | - uses: actions/setup-node@v6 32 | with: { node-version: lts/*, cache: "pnpm" } 33 | - run: pnpm install 34 | - run: pnpm changelogen --bump --canary nightly 35 | - run: npm i -g npm@latest && npm publish --tag latest 36 | -------------------------------------------------------------------------------- /docs/1.guide/2.utils.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: et:tools-2 3 | --- 4 | 5 | # Utilities 6 | 7 | > Unstorage exposes several utilities. You can individually import them and add only the needed bytes to your bundle. 8 | 9 | ## Namespace 10 | 11 | Create a namespaced instance of the main storage. All operations are virtually prefixed, which is useful for creating shorcuts and limiting access. 12 | 13 | `prefixStorage(storage, prefix)`{lang=ts} 14 | 15 | ```ts 16 | import { createStorage, prefixStorage } from "unstorage"; 17 | 18 | const storage = createStorage(); 19 | const assetsStorage = prefixStorage(storage, "assets"); 20 | 21 | // Same as storage.setItem('assets:x', 'hello!') 22 | await assetsStorage.setItem("x", "hello!"); 23 | ``` 24 | 25 | ## Snapshots 26 | 27 | - `snapshot(storage, base?)`{lang=ts} 28 | 29 | Takes a snapshot from all keys in the specified base and stores them in a plain JavaScript object (string: string). Base is removed from keys. 30 | 31 | ```js 32 | import { snapshot } from "unstorage"; 33 | 34 | const data = await snapshot(storage, "/etc"); 35 | ``` 36 | 37 | - `restoreSnapshot(storage, data, base?)`{lang=ts} 38 | 39 | Restore a snapshot created by `snapshot()`. 40 | 41 | ```js 42 | await restoreSnapshot(storage, { "foo:bar": "baz" }, "/etc2"); 43 | ``` 44 | -------------------------------------------------------------------------------- /test/drivers/github.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import driver from "../../src/drivers/github.ts"; 3 | import { createStorage } from "../../src/index.ts"; 4 | 5 | describe("drivers: github", () => { 6 | const storage = createStorage({ 7 | driver: driver({ repo: "unjs/unstorage", branch: "main", dir: "/" }), 8 | }); 9 | 10 | it("can read a repository files", async () => { 11 | const keys = await storage.getKeys(); 12 | expect(keys.length).toBeGreaterThan(10); 13 | }); 14 | 15 | it("can check for a file presence", async () => { 16 | const hasPkg = await storage.hasItem("package.json"); 17 | expect(hasPkg).toBe(true); 18 | }); 19 | 20 | it("can read a json file content", async () => { 21 | const pkg = (await storage.getItem("package.json"))! as Record< 22 | string, 23 | unknown 24 | >; 25 | expect(pkg.name).toBe("unstorage"); 26 | }); 27 | 28 | it("can read an item metadata", async () => { 29 | const pkgMeta = (await storage.getMeta("package.json")) as { 30 | sha: string; 31 | mode: string; 32 | size: number; 33 | }; 34 | expect(pkgMeta.sha.length > 0).toBe(true); 35 | expect(Number(pkgMeta.mode)).toBeGreaterThan(1000); 36 | expect(pkgMeta.size).toBeGreaterThan(1000); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/drivers/deno-kv.test.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { exec, execSync, type ChildProcess } from "node:child_process"; 3 | import { describe, beforeAll, afterAll } from "vitest"; 4 | import { getRandomPort, waitForPort } from "get-port-please"; 5 | import httpDriver from "../../src/drivers/http.ts"; 6 | import { testDriver } from "./utils.ts"; 7 | 8 | let hasDeno: boolean; 9 | // prettier-ignore 10 | try { execSync("deno --version", { stdio: "ignore" }); hasDeno = true } catch { hasDeno = false; } 11 | 12 | describe.skipIf(!hasDeno)("drivers: deno-kv", async () => { 13 | let denoProcess: ChildProcess; 14 | const randomPort = await getRandomPort(); 15 | 16 | beforeAll(async () => { 17 | const fixtureFile = fileURLToPath( 18 | new URL("deno-kv.fixture.ts", import.meta.url) 19 | ); 20 | denoProcess = exec( 21 | `deno run --unstable-kv --unstable-sloppy-imports -A ${fixtureFile}`, 22 | { 23 | env: { 24 | ...process.env, 25 | PORT: randomPort.toString(), 26 | }, 27 | } 28 | ); 29 | await waitForPort(randomPort, { host: "0.0.0.0" }); 30 | }); 31 | 32 | afterAll(() => { 33 | denoProcess.kill(9); 34 | }); 35 | 36 | testDriver({ 37 | driver: httpDriver({ 38 | base: `http://localhost:${randomPort}`, 39 | }), 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /docs/.docs/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/drivers/cloudflare-kv-binding.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { describe, expect, test, afterAll } from "vitest"; 3 | import { createStorage, snapshot } from "../../src/index.ts"; 4 | import CloudflareKVBinding from "../../src/drivers/cloudflare-kv-binding.ts"; 5 | import { testDriver } from "./utils.ts"; 6 | import { getPlatformProxy } from "wrangler"; 7 | 8 | describe("drivers: cloudflare-kv", async () => { 9 | const cfProxy = await getPlatformProxy(); 10 | (globalThis as any).__env__ = cfProxy.env; 11 | afterAll(async () => { 12 | (globalThis as any).__env__ = undefined; 13 | await cfProxy.dispose(); 14 | }); 15 | testDriver({ 16 | driver: CloudflareKVBinding({ base: "base" }), 17 | async additionalTests(ctx) { 18 | test("snapshot", async () => { 19 | await ctx.storage.setItem("s1:a", "test_data"); 20 | await ctx.storage.setItem("s2:a", "test_data"); 21 | await ctx.storage.setItem("s3:a", "test_data"); 22 | 23 | const storage = createStorage({ 24 | driver: CloudflareKVBinding({}), 25 | }); 26 | expect(await snapshot(storage, "")).toMatchInlineSnapshot(` 27 | { 28 | "base:s1:a": "test_data", 29 | "base:s2:a": "test_data", 30 | "base:s3:a": "test_data", 31 | } 32 | `); 33 | }); 34 | }, 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/drivers/redis.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, vi, it, expect } from "vitest"; 2 | import * as ioredisMock from "ioredis-mock"; 3 | import redisDriver from "../../src/drivers/redis.ts"; 4 | import { testDriver } from "./utils.ts"; 5 | 6 | vi.mock("ioredis", () => ({ ...ioredisMock, Redis: ioredisMock.default })); 7 | 8 | describe("drivers: redis", () => { 9 | const driver = redisDriver({ 10 | base: "test:", 11 | url: "ioredis://localhost:6379/0", 12 | lazyConnect: false, 13 | }); 14 | 15 | testDriver({ 16 | driver, 17 | additionalTests(ctx) { 18 | it("verify stored keys", async () => { 19 | await ctx.storage.setItem("s1:a", "test_data"); 20 | await ctx.storage.setItem("s2:a", "test_data"); 21 | await ctx.storage.setItem("s3:a?q=1", "test_data"); 22 | 23 | const client = new (ioredisMock as any).default( 24 | "ioredis://localhost:6379/0" 25 | ); 26 | const keys = await client.keys("*"); 27 | expect(keys).toMatchInlineSnapshot(` 28 | [ 29 | "test:s1:a", 30 | "test:s2:a", 31 | "test:s3:a", 32 | ] 33 | `); 34 | await client.disconnect(); 35 | }); 36 | 37 | it("exposes instance", () => { 38 | expect(driver.getInstance?.()).toBeInstanceOf( 39 | (ioredisMock as any).default 40 | ); 41 | }); 42 | }, 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /docs/2.drivers/http.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: ic:baseline-http 3 | --- 4 | 5 | # HTTP 6 | 7 | > Use a remote HTTP/HTTPS endpoint as data storage. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `http` 12 | 13 | ::note 14 | Supports built-in [http server](/guide/http-server) methods. 15 | :: 16 | 17 | This driver implements meta for each key including `mtime` (last modified time) and `status` from HTTP headers by making a `HEAD` request. 18 | 19 | ```js 20 | import { createStorage } from "unstorage"; 21 | import httpDriver from "unstorage/drivers/http"; 22 | 23 | const storage = createStorage({ 24 | driver: httpDriver({ base: "http://cdn.com" }), 25 | }); 26 | ``` 27 | 28 | **Options:** 29 | 30 | - `base`: Base URL for urls (**required**) 31 | - `headers`: Custom headers to send on all requests 32 | 33 | **Supported HTTP Methods:** 34 | 35 | - `getItem`: Maps to http `GET`. Returns deserialized value if response is ok 36 | - `hasItem`: Maps to http `HEAD`. Returns `true` if response is ok (200) 37 | - `getMeta`: Maps to http `HEAD` (headers: `last-modified` => `mtime`, `x-ttl` => `ttl`) 38 | - `setItem`: Maps to http `PUT`. Sends serialized value using body (`ttl` option will be sent as `x-ttl` header). 39 | - `removeItem`: Maps to `DELETE` 40 | - `clear`: Not supported 41 | 42 | **Transaction Options:** 43 | 44 | - `headers`: Custom headers to be sent on each operation (`getItem`, `setItem`, etc) 45 | - `ttl`: Custom `ttl` (in seconds) for supported drivers. Will be mapped to `x-ttl` http header. 46 | -------------------------------------------------------------------------------- /test/drivers/netlify-blobs.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe } from "vitest"; 2 | import driver from "../../src/drivers/netlify-blobs.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import { BlobsServer } from "@netlify/blobs/server"; 5 | import { resolve } from "node:path"; 6 | import { rm, mkdir } from "node:fs/promises"; 7 | 8 | describe("drivers: netlify-blobs", async () => { 9 | const dataDir = resolve(__dirname, "tmp/netlify-blobs"); 10 | await rm(dataDir, { recursive: true, force: true }).catch(() => {}); 11 | await mkdir(dataDir, { recursive: true }); 12 | 13 | let server: BlobsServer; 14 | const token = "mock"; 15 | const siteID = "1"; 16 | beforeAll(async () => { 17 | server = new BlobsServer({ 18 | directory: dataDir, 19 | debug: !true, 20 | token, 21 | port: 8971, 22 | }); 23 | await server.start(); 24 | }); 25 | 26 | testDriver({ 27 | driver: driver({ 28 | name: "test", 29 | edgeURL: `http://localhost:8971`, 30 | token, 31 | siteID, 32 | }), 33 | }); 34 | 35 | testDriver({ 36 | driver: driver({ 37 | deployScoped: true, 38 | edgeURL: `http://localhost:8971`, 39 | token, 40 | siteID, 41 | deployID: "test", 42 | // Usually defaulted via the environment; only required in a test environment like this 43 | region: "us-east-1", 44 | }), 45 | }); 46 | 47 | afterAll(async () => { 48 | await server.stop(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /docs/2.drivers/fs.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: ph:file-light 3 | --- 4 | 5 | # Filesystem (Node.js) 6 | 7 | > Store data in the filesystem using Node.js API. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `fs` or `fs-lite` 12 | 13 | Maps data to the real filesystem using directory structure for nested keys. Supports watching using [chokidar](https://github.com/paulmillr/chokidar). 14 | 15 | This driver implements meta for each key including `mtime` (last modified time), `atime` (last access time), and `size` (file size) using `fs.stat`. 16 | 17 | ```js 18 | import { createStorage } from "unstorage"; 19 | import fsDriver from "unstorage/drivers/fs"; 20 | 21 | const storage = createStorage({ 22 | driver: fsDriver({ base: "./tmp" }), 23 | }); 24 | ``` 25 | 26 | **Options:** 27 | 28 | - `base`: Base directory to isolate operations on this directory 29 | - `ignore`: Ignore patterns for watch 30 | - `watchOptions`: Additional [chokidar](https://github.com/paulmillr/chokidar) options. 31 | 32 | ## Node.js Filesystem (Lite) 33 | 34 | This driver uses pure Node.js API without extra dependencies. 35 | 36 | ```js 37 | import { createStorage } from "unstorage"; 38 | import fsLiteDriver from "unstorage/drivers/fs-lite"; 39 | 40 | const storage = createStorage({ 41 | driver: fsLiteDriver({ base: "./tmp" }), 42 | }); 43 | ``` 44 | 45 | **Options:** 46 | 47 | - `base`: Base directory to isolate operations on this directory 48 | - `ignore`: Optional callback function `(path: string) => boolean` 49 | -------------------------------------------------------------------------------- /src/drivers/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type { Driver } from "../../types.ts"; 2 | 3 | type DriverFactory = ( 4 | opts: OptionsT 5 | ) => Driver; 6 | interface ErrorOptions {} 7 | 8 | export function defineDriver( 9 | factory: DriverFactory 10 | ): DriverFactory { 11 | return factory; 12 | } 13 | 14 | export function normalizeKey( 15 | key: string | undefined, 16 | sep: ":" | "/" = ":" 17 | ): string { 18 | if (!key) { 19 | return ""; 20 | } 21 | return key.replace(/[:/\\]/g, sep).replace(/^[:/\\]|[:/\\]$/g, ""); 22 | } 23 | 24 | export function joinKeys(...keys: string[]) { 25 | return keys 26 | .map((key) => normalizeKey(key)) 27 | .filter(Boolean) 28 | .join(":"); 29 | } 30 | 31 | export function createError( 32 | driver: string, 33 | message: string, 34 | opts?: ErrorOptions 35 | ) { 36 | const err = new Error(`[unstorage] [${driver}] ${message}`, opts); 37 | if (Error.captureStackTrace) { 38 | Error.captureStackTrace(err, createError); 39 | } 40 | return err; 41 | } 42 | 43 | export function createRequiredError(driver: string, name: string | string[]) { 44 | if (Array.isArray(name)) { 45 | return createError( 46 | driver, 47 | `Missing some of the required options ${name 48 | .map((n) => "`" + n + "`") 49 | .join(", ")}` 50 | ); 51 | } 52 | return createError(driver, `Missing required option \`${name}\`.`); 53 | } 54 | -------------------------------------------------------------------------------- /docs/1.guide/4.custom-driver.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: carbon:area-custom 3 | --- 4 | 5 | # Custom Driver 6 | 7 | > It is possible to extend `unstorage` by creating a custom driver. 8 | 9 | Explore [src/drivers](https://github.com/unjs/unstorage/tree/main/src/drivers) to get an idea of how to implement them. Methods can: 10 | 11 | ```js 12 | import { createStorage, defineDriver } from "unstorage"; 13 | 14 | const myStorageDriver = defineDriver((options) => { 15 | return { 16 | name: "my-custom-driver", 17 | options, 18 | async hasItem(key, _opts) {}, 19 | async getItem(key, _opts) {}, 20 | async setItem(key, value, _opts) {}, 21 | async removeItem(key, _opts) {}, 22 | async getKeys(base, _opts) {}, 23 | async clear(base, _opts) {}, 24 | async dispose() {}, 25 | async watch(callback) {}, 26 | }; 27 | }); 28 | 29 | const storage = createStorage({ 30 | driver: myStorageDriver(), 31 | }); 32 | ``` 33 | 34 | Some important notes: 35 | 36 | - Keys should be normalized following `foo:bar` convention 37 | - Remove any open watcher and handlers in `dispose()` 38 | - Returning a promise is optional, you can return a direct value (see [memory driver](https://github.com/unjs/unstorage/blob/main/src/drivers/memory.ts)) 39 | - You don't have acces to the mount base 40 | - Value returned by `getItem` can be a serializable `object` or `string` 41 | - When setting `watch` method, the unstorage default handler will be disabled. You are responsible for emitting an event on `getItem`, `setItem` and `removeItem`. 42 | -------------------------------------------------------------------------------- /docs/2.drivers/mongodb.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: teenyicons:mongodb-outline 3 | --- 4 | 5 | # MongoDB 6 | 7 | > Store data in MongoDB using Node.js MongoDB package. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `mongodb` 12 | 13 | ::read-more{to="https://www.mongodb.com/"} 14 | Learn more about MongoDB. 15 | :: 16 | 17 | This driver stores KV information in a MongoDB collection with a separate document for each key value pair. 18 | 19 | To use it, you will need to install `mongodb` in your project: 20 | 21 | :pm-install{name="mongodb"} 22 | 23 | Usage: 24 | 25 | ```js 26 | import { createStorage } from "unstorage"; 27 | import mongodbDriver from "unstorage/drivers/mongodb"; 28 | 29 | const storage = createStorage({ 30 | driver: mongodbDriver({ 31 | connectionString: "CONNECTION_STRING", 32 | databaseName: "test", 33 | collectionName: "test", 34 | }), 35 | }); 36 | ``` 37 | 38 | **Authentication:** 39 | 40 | The driver supports the following authentication methods: 41 | 42 | - **`connectionString`**: The MongoDB connection string. This is the only way to authenticate. 43 | 44 | **Options:** 45 | 46 | - **`connectionString`** (required): The connection string to use to connect to the MongoDB database. It should be in the format `mongodb://:@:/`. 47 | - `databaseName`: The name of the database to use. Defaults to `unstorage`. 48 | - `collectionName`: The name of the collection to use. Defaults to `unstorage`. 49 | - `clientOptions`: Optional configuration settings for the MongoClient instance. 50 | -------------------------------------------------------------------------------- /test/drivers/s3.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import s3Driver from "../../src/drivers/s3.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import { AwsClient } from "aws4fetch"; 5 | 6 | const accessKeyId = process.env.VITE_S3_ACCESS_KEY_ID; 7 | const secretAccessKey = process.env.VITE_S3_SECRET_ACCESS_KEY; 8 | const bucket = process.env.VITE_S3_BUCKET; 9 | const endpoint = process.env.VITE_S3_ENDPOINT; 10 | const region = process.env.VITE_S3_REGION; 11 | 12 | describe.skipIf( 13 | !accessKeyId || !secretAccessKey || !bucket || !endpoint || !region 14 | )("drivers: s3", () => { 15 | testDriver({ 16 | driver: () => 17 | s3Driver({ 18 | accessKeyId: accessKeyId!, 19 | secretAccessKey: secretAccessKey!, 20 | bucket: bucket!, 21 | endpoint: endpoint!, 22 | region: region!, 23 | }), 24 | additionalTests(ctx) { 25 | it("can access directly with / separator", async () => { 26 | await ctx.storage.set("foo/bar:baz", "ok"); 27 | expect(await ctx.storage.get("foo/bar:baz")).toBe("ok"); 28 | 29 | const client = new AwsClient({ 30 | accessKeyId: accessKeyId!, 31 | secretAccessKey: secretAccessKey!, 32 | region, 33 | }); 34 | const response = await client.fetch( 35 | `${endpoint}/${bucket}/foo/bar/baz`, 36 | { 37 | method: "GET", 38 | } 39 | ); 40 | expect(response.status).toBe(200); 41 | expect(await response.text()).toBe("ok"); 42 | }); 43 | }, 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/drivers/mongodb.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, describe, expect, it } from "vitest"; 2 | import driver from "../../src/drivers/mongodb.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import { MongoMemoryServer } from "mongodb-memory-server"; 5 | import { promisify } from "node:util"; 6 | 7 | describe("drivers: mongodb", async () => { 8 | const sleep = promisify(setTimeout); 9 | 10 | const mongoServer = await MongoMemoryServer.create(); 11 | const connectionString = mongoServer.getUri(); 12 | 13 | afterAll(async () => { 14 | if (mongoServer) { 15 | await mongoServer.stop(); 16 | } 17 | }); 18 | 19 | testDriver({ 20 | driver: driver({ 21 | connectionString: connectionString as string, 22 | databaseName: "test", 23 | collectionName: "test", 24 | }), 25 | additionalTests: (ctx) => { 26 | it("should throw error if no connection string is provided", async () => { 27 | await expect(() => 28 | driver({ 29 | databaseName: "test", 30 | collectionName: "test", 31 | } as any).getItem("") 32 | ).rejects.toThrowError( 33 | "[unstorage] [mongodb] Missing required option `connectionString`." 34 | ); 35 | }); 36 | it("should have different dates when an entry was updated", async () => { 37 | await ctx.storage.setItem("s1:a", "test_data"); 38 | await sleep(100); 39 | await ctx.storage.setItem("s1:a", "updated_test_data"); 40 | const result = await ctx.storage.getMeta("s1:a"); 41 | expect(result.mtime).not.toBe(result.birthtime); 42 | }); 43 | }, 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/drivers/indexedb.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver } from "./utils/index.ts"; 2 | import { 3 | get, 4 | set, 5 | clear, 6 | del, 7 | keys, 8 | createStore, 9 | type UseStore, 10 | } from "idb-keyval"; 11 | 12 | export interface IDBKeyvalOptions { 13 | base?: string; 14 | dbName?: string; 15 | storeName?: string; 16 | } 17 | 18 | const DRIVER_NAME = "idb-keyval"; 19 | 20 | export default defineDriver((opts: IDBKeyvalOptions = {}) => { 21 | const base = opts.base && opts.base.length > 0 ? `${opts.base}:` : ""; 22 | const makeKey = (key: string) => base + key; 23 | 24 | let customStore: UseStore | undefined; 25 | if (opts.dbName && opts.storeName) { 26 | customStore = createStore(opts.dbName, opts.storeName); 27 | } 28 | 29 | return { 30 | name: DRIVER_NAME, 31 | options: opts, 32 | async hasItem(key) { 33 | const item = await get(makeKey(key), customStore); 34 | return item === undefined ? false : true; 35 | }, 36 | async getItem(key) { 37 | const item = await get(makeKey(key), customStore); 38 | return item ?? null; 39 | }, 40 | async getItemRaw(key) { 41 | const item = await get(makeKey(key), customStore); 42 | return item ?? null; 43 | }, 44 | setItem(key, value) { 45 | return set(makeKey(key), value, customStore); 46 | }, 47 | setItemRaw(key, value) { 48 | return set(makeKey(key), value, customStore); 49 | }, 50 | removeItem(key) { 51 | return del(makeKey(key), customStore); 52 | }, 53 | getKeys() { 54 | return keys(customStore); 55 | }, 56 | clear() { 57 | return clear(customStore); 58 | }, 59 | }; 60 | }); 61 | -------------------------------------------------------------------------------- /docs/2.drivers/database.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: ph:database 3 | --- 4 | 5 | # SQL Database 6 | 7 | > Store data in any SQL database. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `db0` 12 | 13 | This driver stores KV data in any SQL database using [db0](https://db0.unjs.io). 14 | 15 | ::warning 16 | Database driver is experimental and behavior may change in the future. 17 | :: 18 | 19 | To use, you will need to install `db0` in your project: 20 | 21 | :pm-install{name="db0"} 22 | 23 | Select and configure the appropriate connector for your database. 24 | 25 | ::important{to="https://db0.unjs.io/connectors"} 26 | Learn more about configuring connectors in the `db0` documentation. 27 | :: 28 | 29 | You can then configure the driver like this: 30 | 31 | ```js 32 | import { createDatabase } from "db0"; 33 | import { createStorage } from "unstorage"; 34 | import dbDriver from "unstorage/drivers/db0"; 35 | import sqlite from "db0/connectors/better-sqlite3"; 36 | 37 | // Learn more: https://db0.unjs.io 38 | const database = createDatabase( 39 | sqlite({ 40 | /* db0 connector options */ 41 | }) 42 | ); 43 | 44 | const storage = createStorage({ 45 | driver: dbDriver({ 46 | database, 47 | tableName: "custom_table_name", // Default is "unstorage" 48 | }), 49 | }); 50 | ``` 51 | 52 | ::tip 53 | The database table is automatically created, no additional setup is required!
54 | Before first operation, driver ensures a table with columns of `id`, `value`, `blob`, `created_at` and `updated_at` exist. 55 | :: 56 | 57 | **Options:** 58 | 59 | - **`database`** (required): A `db0` database instance. 60 | - `tableName`: The name of the table to use. It defaults to `unstorage`. 61 | -------------------------------------------------------------------------------- /docs/2.drivers/upstash.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: simple-icons:upstash 3 | --- 4 | 5 | # Upstash 6 | 7 | > Store data in an Upstash Redis database. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `upstash` 12 | 13 | ::read-more{to="https://upstash.com/"} 14 | Learn more about Upstash. 15 | :: 16 | 17 | ::note 18 | Unstorage uses [`@upstash/redis`](https://github.com/upstash/upstash-redis) internally to connect to Upstash Redis. 19 | :: 20 | 21 | To use it, you will need to install `@upstash/redis` in your project: 22 | 23 | :pm-install{name="@upstash/redis"} 24 | 25 | Usage with Upstash Redis: 26 | 27 | ```js 28 | import { createStorage } from "unstorage"; 29 | import upstashDriver from "unstorage/drivers/upstash"; 30 | 31 | const storage = createStorage({ 32 | driver: upstashDriver({ 33 | base: "unstorage", 34 | // url: "", // or set UPSTASH_REDIS_REST_URL env 35 | // token: "", // or set UPSTASH_REDIS_REST_TOKEN env 36 | }), 37 | }); 38 | ``` 39 | 40 | **Options:** 41 | 42 | - `base`: Optional prefix to use for all keys. Can be used for namespacing. 43 | - `url`: The REST URL for your Upstash Redis database. Find it in [the Upstash Redis console](https://console.upstash.com/redis/). Driver uses `UPSTASH_REDIS_REST_URL` environment by default. 44 | - `token`: The REST token for authentication with your Upstash Redis database. Find it in [the Upstash Redis console](https://console.upstash.com/redis/). Driver uses `UPSTASH_REDIS_REST_TOKEN` environment by default. 45 | - `ttl`: Default TTL for all items in **seconds**. 46 | - `scanCount`: How many keys to scan at once. 47 | 48 | See [@upstash/redis documentation](https://upstash.com/docs/redis/sdks/ts/overview) for all available options. 49 | 50 | **Transaction options:** 51 | 52 | - `ttl`: Supported for `setItem(key, value, { ttl: number /* seconds */ })` 53 | -------------------------------------------------------------------------------- /src/drivers/capacitor-preferences.ts: -------------------------------------------------------------------------------- 1 | import { Preferences } from "@capacitor/preferences"; 2 | 3 | import { defineDriver, joinKeys, normalizeKey } from "./utils/index.ts"; 4 | 5 | const DRIVER_NAME = "capacitor-preferences"; 6 | 7 | export interface CapacitorPreferencesOptions { 8 | base?: string; 9 | } 10 | 11 | export default defineDriver( 12 | (opts) => { 13 | const base = normalizeKey(opts?.base || ""); 14 | const resolveKey = (key: string) => joinKeys(base, key); 15 | 16 | return { 17 | name: DRIVER_NAME, 18 | options: opts, 19 | getInstance: () => Preferences, 20 | hasItem(key) { 21 | return Preferences.keys().then((r) => r.keys.includes(resolveKey(key))); 22 | }, 23 | getItem(key) { 24 | return Preferences.get({ key: resolveKey(key) }).then((r) => r.value); 25 | }, 26 | getItemRaw(key) { 27 | return Preferences.get({ key: resolveKey(key) }).then((r) => r.value); 28 | }, 29 | setItem(key, value) { 30 | return Preferences.set({ key: resolveKey(key), value }); 31 | }, 32 | setItemRaw(key, value) { 33 | return Preferences.set({ key: resolveKey(key), value }); 34 | }, 35 | removeItem(key) { 36 | return Preferences.remove({ key: resolveKey(key) }); 37 | }, 38 | async getKeys() { 39 | const { keys } = await Preferences.keys(); 40 | return keys.map((key) => key.slice(base.length)); 41 | }, 42 | async clear(prefix) { 43 | const { keys } = await Preferences.keys(); 44 | const _prefix = resolveKey(prefix || ""); 45 | await Promise.all( 46 | keys 47 | .filter((key) => key.startsWith(_prefix)) 48 | .map((key) => Preferences.remove({ key })) 49 | ); 50 | }, 51 | }; 52 | } 53 | ); 54 | -------------------------------------------------------------------------------- /test/drivers/indexedb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import driver from "../../src/drivers/indexedb.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import "fake-indexeddb/auto"; 5 | import { createStorage } from "../../src/index.ts"; 6 | 7 | describe("drivers: indexeddb", () => { 8 | testDriver({ driver: driver({ dbName: "test-db" }) }); 9 | 10 | const customStorage = createStorage({ 11 | driver: driver({ 12 | dbName: "custom-db", 13 | storeName: "custom-name", 14 | base: "unstorage", 15 | }), 16 | }); 17 | 18 | it("can use a customStore", async () => { 19 | await customStorage.setItem("first", "foo"); 20 | await customStorage.setItem("second", "bar"); 21 | expect(await customStorage.getItem("first")).toBe("foo"); 22 | await customStorage.removeItem("first"); 23 | expect(await customStorage.getKeys()).toMatchObject(["unstorage:second"]); 24 | await customStorage.clear(); 25 | expect(await customStorage.hasItem("second")).toBe(false); 26 | }); 27 | 28 | it("properly handle raw items", async () => { 29 | await customStorage.setItem("object", { item: "foo" }); 30 | await customStorage.setItemRaw("rawObject", { item: "foo" }); 31 | expect(await customStorage.getItemRaw("object")).toBe('{"item":"foo"}'); 32 | expect(await customStorage.getItemRaw("rawObject")).toStrictEqual({ 33 | item: "foo", 34 | }); 35 | await customStorage.setItem("number", 1234); 36 | await customStorage.setItemRaw("rawNumber", 1234); 37 | expect(await customStorage.getItemRaw("number")).toBe("1234"); 38 | expect(await customStorage.getItemRaw("rawNumber")).toBe(1234); 39 | await customStorage.setItem("boolean", true); 40 | await customStorage.setItemRaw("rawBoolean", true); 41 | expect(await customStorage.getItemRaw("boolean")).toBe("true"); 42 | expect(await customStorage.getItemRaw("rawBoolean")).toBe(true); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/drivers/http.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, afterAll, expect, it } from "vitest"; 2 | import { serve } from "srvx"; 3 | import driver from "../../src/drivers/http.ts"; 4 | import { createStorage } from "../../src/index.ts"; 5 | import { createStorageHandler } from "../../src/server.ts"; 6 | import { testDriver } from "./utils.ts"; 7 | 8 | describe("drivers: http", async () => { 9 | const remoteStorage = createStorage(); 10 | const server = createStorageHandler(remoteStorage, { 11 | authorize({ key, request }) { 12 | if (request.headers.get("x-global-header") !== "1") { 13 | // console.log(req.key, req.type, req.event.node.req.headers); 14 | throw new Error("Missing global test header!"); 15 | } 16 | if ( 17 | key === "authorized" && 18 | request.headers.get("x-auth-header") !== "1" 19 | ) { 20 | // console.log(req.key, req.type, req.event.node.req.headers); 21 | throw new Error("Missing auth test header!"); 22 | } 23 | }, 24 | }); 25 | const listener = await serve({ 26 | fetch: server, 27 | port: 0, 28 | }); 29 | 30 | afterAll(async () => { 31 | await listener.close(); 32 | }); 33 | 34 | testDriver({ 35 | driver: driver({ 36 | base: listener!.url!, 37 | headers: { "x-global-header": "1" }, 38 | }), 39 | async additionalTests(ctx) { 40 | it("custom headers", async () => { 41 | await ctx.storage.setItem("authorized", "test", { 42 | headers: { "x-auth-header": "1" }, 43 | }); 44 | }); 45 | it("null item", async () => { 46 | await ctx.storage.setItem("nullItem", null); 47 | await ctx.storage.setItem("nullStringItem", "null"); 48 | expect(await ctx.storage.getItem("nullItem")).toBeNull(); 49 | expect(await ctx.storage.getItem("nanItem")).toBeNull(); 50 | expect(await ctx.storage.getItem("nullStringItem")).toBeNull(); 51 | }); 52 | }, 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/drivers/lru-cache.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver } from "./utils/index.ts"; 2 | import { LRUCache } from "lru-cache"; 3 | 4 | type LRUCacheOptions = LRUCache.OptionsBase & 5 | Partial> & 6 | Partial> & 7 | Partial>; 8 | 9 | export interface LRUDriverOptions extends LRUCacheOptions {} 10 | 11 | const DRIVER_NAME = "lru-cache"; 12 | 13 | export default defineDriver((opts: LRUDriverOptions = {}) => { 14 | const cache = new LRUCache({ 15 | max: 1000, 16 | sizeCalculation: 17 | opts.maxSize || opts.maxEntrySize 18 | ? (value, key: string) => { 19 | return key.length + byteLength(value); 20 | } 21 | : undefined, 22 | ...opts, 23 | }); 24 | 25 | return { 26 | name: DRIVER_NAME, 27 | options: opts, 28 | getInstance: () => cache, 29 | hasItem(key) { 30 | return cache.has(key); 31 | }, 32 | getItem(key) { 33 | return cache.get(key) ?? null; 34 | }, 35 | getItemRaw(key) { 36 | return cache.get(key) ?? null; 37 | }, 38 | setItem(key, value) { 39 | cache.set(key, value); 40 | }, 41 | setItemRaw(key, value) { 42 | cache.set(key, value); 43 | }, 44 | removeItem(key) { 45 | cache.delete(key); 46 | }, 47 | getKeys() { 48 | return [...cache.keys()]; 49 | }, 50 | clear() { 51 | cache.clear(); 52 | }, 53 | dispose() { 54 | cache.clear(); 55 | }, 56 | }; 57 | }); 58 | 59 | function byteLength(value: any) { 60 | if (typeof Buffer !== "undefined") { 61 | try { 62 | return Buffer.byteLength(value); 63 | } catch { 64 | // ignore 65 | } 66 | } 67 | try { 68 | return typeof value === "string" 69 | ? value.length 70 | : JSON.stringify(value).length; 71 | } catch { 72 | // ignore 73 | } 74 | return 0; 75 | } 76 | -------------------------------------------------------------------------------- /test/drivers/cloudflare-r2-binding.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { describe, test, expect, afterAll } from "vitest"; 3 | import { createStorage, snapshot } from "../../src/index.ts"; 4 | import CloudflareR2Binding from "../../src/drivers/cloudflare-r2-binding.ts"; 5 | import { testDriver } from "./utils.ts"; 6 | import { getPlatformProxy } from "wrangler"; 7 | 8 | describe("drivers: cloudflare-r2-binding", async () => { 9 | const cfProxy = await getPlatformProxy(); 10 | (globalThis as any).__env__ = cfProxy.env; 11 | afterAll(async () => { 12 | (globalThis as any).__env__ = undefined; 13 | await cfProxy.dispose(); 14 | }); 15 | 16 | testDriver({ 17 | driver: CloudflareR2Binding({ base: "base" }), 18 | async additionalTests(ctx) { 19 | test("snapshot", async () => { 20 | await ctx.storage.setItem("s1:a", "test_data"); 21 | await ctx.storage.setItem("s2:a", "test_data"); 22 | await ctx.storage.setItem("s3:a", "test_data"); 23 | 24 | const storage = createStorage({ 25 | driver: CloudflareR2Binding({}), 26 | }); 27 | 28 | const storageSnapshot = await snapshot(storage, ""); 29 | 30 | expect(storageSnapshot).toMatchInlineSnapshot(` 31 | { 32 | "base:s1:a": "test_data", 33 | "base:s2:a": "test_data", 34 | "base:s3:a": "test_data", 35 | } 36 | `); 37 | }); 38 | test("native meta", async () => { 39 | await ctx.storage.setItem("s1:a", "test_data"); 40 | const meta = await ctx.storage.getMeta("/s1/a"); 41 | expect(meta).toEqual( 42 | expect.objectContaining({ 43 | atime: expect.any(Date), 44 | mtime: expect.any(Date), 45 | size: expect.any(Number), 46 | }) 47 | ); 48 | const nonExistentMeta = await ctx.storage.getMeta("/s1/nonexistent"); 49 | expect(nonExistentMeta).toEqual({}); 50 | }); 51 | }, 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /docs/2.drivers/s3.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: simple-icons:amazons3 3 | --- 4 | 5 | # S3 6 | 7 | > Store data to storage to S3-compatible providers. 8 | 9 | S3 driver allows storing KV data to [Amazon S3](https://aws.amazon.com/s3/) or any other S3-compatible provider. 10 | 11 | Driver implementation is lightweight and based on [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) working with Node.js as well as edge workers. 12 | 13 | ## Usage 14 | 15 | **Driver name:** `s3` 16 | 17 | ### Setup 18 | 19 | Setup a "Bucket" in your S3-compatible provider. You need this info: 20 | 21 | - Access Key ID 22 | - Secret Access Key 23 | - Bucket name 24 | - Endpoint 25 | - Region 26 | 27 | Make sure to install required peer dependencies: 28 | 29 | :pm-install{name="aws4fetch"} 30 | 31 | Then please make sure to set all driver's options: 32 | 33 | ```ts 34 | import { createStorage } from "unstorage"; 35 | import s3Driver from "unstorage/drivers/s3"; 36 | 37 | const storage = createStorage({ 38 | driver: s3Driver({ 39 | accessKeyId: "", // Access Key ID 40 | secretAccessKey: "", // Secret Access Key 41 | endpoint: "", 42 | bucket: "", 43 | region: "", 44 | }), 45 | }); 46 | ``` 47 | 48 | **Options:** 49 | 50 | - `bulkDelete`: Enabled by default to speedup `clear()` operation. Set to `false` if provider is not implementing [DeleteObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html). 51 | 52 | ## Tested providers 53 | 54 | Any S3-compatible provider should work out of the box. 55 | Pull-Requests are more than welcome to add info about other any other tested provider. 56 | 57 | ### Amazon S3 58 | 59 | :read-more{to="https://aws.amazon.com/s3/" title="Amazon S3"} 60 | 61 | Options: 62 | 63 | - Set `endpoint` to `https://s3.[region].amazonaws.com/` 64 | 65 | ### Cloudflare R2 66 | 67 | :read-more{to="https://www.cloudflare.com/developer-platform/products/r2/" title="Cloudflare R2"} 68 | 69 | Options: 70 | 71 | - Set `endpoint` to `https://[uid].r2.cloudflarestorage.com/` 72 | - Set `region` to `auto` 73 | -------------------------------------------------------------------------------- /test/drivers/vercel-runtime-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import vercelRuntimeCacheDriver from "../../src/drivers/vercel-runtime-cache.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | 5 | describe("drivers: vercel-runtime-cache", async () => { 6 | testDriver({ 7 | driver: vercelRuntimeCacheDriver({ 8 | base: Math.round(Math.random() * 1_000_000).toString(16), 9 | // Configure tags so clear() can expire them 10 | tags: ["unstorage-test"], 11 | }), 12 | noKeysSupport: true, 13 | additionalTests: (c) => { 14 | it("set/get/has/remove", async () => { 15 | expect(await c.storage.hasItem("k1")).toBe(false); 16 | await c.storage.setItem("k1", "v1"); 17 | expect(await c.storage.hasItem("k1")).toBe(true); 18 | expect(await c.storage.getItem("k1")).toBe("v1"); 19 | await c.storage.removeItem("k1"); 20 | expect(await c.storage.hasItem("k1")).toBe(false); 21 | expect(await c.storage.getItem("k1")).toBe(null); 22 | }); 23 | 24 | it("getMeta returns {} for existing and null for missing", async () => { 25 | await c.storage.setItem("meta-key", "meta-value"); 26 | expect(await c.storage.getMeta("meta-key")).toMatchObject({}); 27 | await c.storage.removeItem("meta-key"); 28 | expect(await c.storage.getItem("meta-key")).toBe(null); 29 | expect(await c.storage.hasItem("meta-key")).toBe(false); 30 | }); 31 | 32 | it("getKeys is not supported (returns empty list)", async () => { 33 | await c.storage.setItem("a", "1"); 34 | await c.storage.setItem("b", "2"); 35 | expect(await c.storage.getKeys()).toMatchObject([]); 36 | }); 37 | 38 | it("clear expires by tags when configured", async () => { 39 | await c.storage.setItem("t:1", "v"); 40 | expect(await c.storage.getItem("t:1")).toBe("v"); 41 | await c.storage.clear(); 42 | expect(await c.storage.getItem("t:1")).toBe(null); 43 | }); 44 | }, 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /docs/2.drivers/planetscale.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: simple-icons:planetscale 3 | --- 4 | 5 | # PlanetScale 6 | 7 | > Store data in MySQL database via PlanetScale. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `planetscale` 12 | 13 | ::read-more{to="https://planetscale.com/"} 14 | Learn more about PlanetScale. 15 | :: 16 | 17 | This driver stores KV information in a Planetscale DB with columns of `id`, `value`, `created_at` and `updated_at`. 18 | 19 | To use, you will need to install `@planetscale/database` in your project: 20 | 21 | ```json 22 | { 23 | "dependencies": { 24 | "@planetscale/database": "^1.5.0" 25 | } 26 | } 27 | ``` 28 | 29 | Then you can create a table to store your data by running the following query in your Planetscale database, where `` is the name of the table you want to use: 30 | 31 | ``` 32 | create table ( 33 | id varchar(255) not null primary key, 34 | value longtext, 35 | created_at timestamp default current_timestamp, 36 | updated_at timestamp default current_timestamp on update current_timestamp 37 | ); 38 | ``` 39 | 40 | You can then configure the driver like this: 41 | 42 | ```js 43 | import { createStorage } from "unstorage"; 44 | import planetscaleDriver from "unstorage/drivers/planetscale"; 45 | 46 | const storage = createStorage({ 47 | driver: planetscaleDriver({ 48 | // This should certainly not be inlined in your code but loaded via runtime config 49 | // or environment variables depending on your framework/project. 50 | url: "mysql://xxxxxxxxx:************@xxxxxxxxxx.us-east-3.psdb.cloud/my-database?sslaccept=strict", 51 | // table: 'storage' 52 | }), 53 | }); 54 | ``` 55 | 56 | **Options:** 57 | 58 | - **`url`** (required): You can find your URL in the [Planetscale dashboard](https://planetscale.com/docs/tutorials/connect-nodejs-app). 59 | - `table`: The name of the table to read from. It defaults to `storage`. 60 | - `boostCache`: Whether to enable cached queries: See [docs](https://planetscale.com/docs/concepts/query-caching-with-planetscale-boost#using-cached-queries-in-your-application). 61 | -------------------------------------------------------------------------------- /docs/2.drivers/browser.md: -------------------------------------------------------------------------------- 1 | ---- 2 | icon: ph:browser-thin 3 | --- 4 | 5 | # Browser 6 | 7 | > Store data in `localStorage`, `sessionStorage` or `IndexedDB` 8 | 9 | ## LocalStorage / SessionStorage 10 | 11 | ### Usage 12 | 13 | **Driver name:** `localstorage` or `sessionstorage` 14 | 15 | Store data in [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) or [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage.) 16 | 17 | ```js 18 | import { createStorage } from "unstorage"; 19 | import localStorageDriver from "unstorage/drivers/localstorage"; 20 | 21 | const storage = createStorage({ 22 | driver: localStorageDriver({ base: "app:" }), 23 | }); 24 | ``` 25 | 26 | **Options:** 27 | 28 | - `base`: Add base to all keys to avoid collision 29 | - `storage`: (optional) provide `localStorage` or `sessionStorage` compatible object. 30 | - `windowKey`: (optional) Can be `"localStorage"` (default) or `"sessionStorage"` 31 | - `window`: (optional) provide `window` object 32 | 33 | ## IndexedDB 34 | 35 | Store key-value in IndexedDB. 36 | 37 | ### Usage 38 | 39 | **Driver name:** `indexeddb` 40 | 41 | ::read-more{to="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API"} 42 | Learn more about IndexedDB. 43 | :: 44 | 45 | To use it, you will need to install [`idb-keyval`](https://github.com/jakearchibald/idb-keyval) in your project: 46 | 47 | :pm-install{name="idb-keyval"} 48 | 49 | Usage: 50 | 51 | ```js 52 | import { createStorage } from "unstorage"; 53 | import indexedDbDriver from "unstorage/drivers/indexedb"; 54 | 55 | const storage = createStorage({ 56 | driver: indexedDbDriver({ base: "app:" }), 57 | }); 58 | ``` 59 | 60 | ::note 61 | By default, unstorage will `JSON.stringify` the value before passing to IndexedDB. If you want objects to be stored "as-is", you can use `storage.setItemRaw`. 62 | :: 63 | 64 | **Options:** 65 | 66 | - `base`: Add `${base}:` to all keys to avoid collision 67 | - `dbName`: Custom name for database. Defaults to `keyval-store` 68 | - `storeName`: Custom name for store. Defaults to `keyval` 69 | 70 | ::note 71 | IndexedDB is a browser database. Avoid using this preset on server environments. 72 | :: 73 | -------------------------------------------------------------------------------- /docs/1.guide/3.http-server.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: ic:baseline-http 3 | --- 4 | 5 | # HTTP Server 6 | 7 | > We can expose unstorage's instance to an HTTP server to allow remote connections. 8 | 9 | Request url is mapped to a key and method/body is mapped to a function. See below for supported HTTP methods. 10 | 11 | ## Storage Server 12 | 13 | Programmatic usage of creating an HTTP server exposing methods to communicate with the `storage` instance: 14 | 15 | ```js [server.js] 16 | import { listen } from "listhen"; 17 | import { createStorage } from "unstorage"; 18 | import { createStorageServer } from "unstorage/server"; 19 | 20 | const storage = createStorage(); 21 | const storageServer = createStorageServer(storage, { 22 | authorize(req) { 23 | // req: { key, type, event } 24 | if (req.type === "read" && req.key.startsWith("private:")) { 25 | throw new Error("Unauthorized Read"); 26 | } 27 | }, 28 | }); 29 | 30 | // Alternatively we can use `storageServer.handle` as a middleware 31 | await listen(storageServer.handle); 32 | ``` 33 | 34 | The `storageServer` is an [h3](https://github.com/unjs/h3) instance. Check out also [listhen](https://github.com/unjs/listhen) for an elegant HTTP listener. 35 | 36 | ::warning 37 | **🛡️ Security Note:** Make sure to always implement `authorize` in order to protect the server when it is exposed to a production environment. 38 | :: 39 | 40 | ## Storage Client 41 | 42 | You can use the [http driver](/drivers/http) to easily connect to the server. 43 | 44 | ```ts 45 | import { createStorage } from "unstorage"; 46 | import httpDriver from "unstorage/drivers/http"; 47 | 48 | const client = createStorage({ 49 | driver: httpDriver({ 50 | base: "SERVER_ENDPOINT", 51 | }), 52 | }); 53 | const keys = await client.getKeys(); 54 | ``` 55 | 56 | ## HTTP Methods 57 | 58 | - `GET`: Maps to `storage.getItem` or `storage.getKeys` when the path ends with `/` or `/:` 59 | - `HEAD`: Maps to `storage.hasItem`. Returns 404 if not found. 60 | - `PUT`: Maps to `storage.setItem`. Value is read from the body and returns `OK` if the operation succeeded. 61 | - `DELETE`: Maps to `storage.removeItem` or `storage.clear` when the path ends with `/` or `/:`. Returns `OK` if the operation succeeded. 62 | 63 | ::note 64 | When passing `accept: application/octet-stream` for GET and SET operations, the server switches to binary mode via `getItemRaw` and `setItemRaw`. 65 | :: 66 | -------------------------------------------------------------------------------- /test/drivers/db0.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, describe, expect, it } from "vitest"; 2 | import { createDatabase } from "db0"; 3 | import db0Driver from "../../src/drivers/db0.ts"; 4 | import { testDriver } from "./utils.ts"; 5 | 6 | const drivers = [ 7 | { 8 | name: "sqlite", 9 | async getDB() { 10 | const sqlite = await import("db0/connectors/node-sqlite").then( 11 | (m) => m.default 12 | ); 13 | return createDatabase(sqlite({ name: ":memory:" })); 14 | }, 15 | }, 16 | { 17 | name: "libsql", 18 | async getDB() { 19 | const libSQL = await import("db0/connectors/libsql/node").then( 20 | (m) => m.default 21 | ); 22 | return createDatabase(libSQL({ url: ":memory:" })); 23 | }, 24 | }, 25 | { 26 | name: "pglite", 27 | async getDB() { 28 | const pglite = await import("db0/connectors/pglite").then( 29 | (m) => m.default 30 | ); 31 | return createDatabase(pglite()); 32 | }, 33 | }, 34 | // docker run -it --rm --name mysql -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=unstorage -p 3306:3306 mysql 35 | // VITEST_MYSQL_URI=mysql://root:root@localhost/unstorage pnpm vitest test/drivers/db0.test.ts -t mysql 36 | { 37 | name: "mysql", 38 | enabled: !!process.env.VITEST_MYSQL_URI, 39 | async getDB() { 40 | const mysql = await import("db0/connectors/mysql2").then( 41 | (m) => m.default 42 | ); 43 | return createDatabase( 44 | mysql({ 45 | uri: process.env.VITEST_MYSQL_URI, 46 | }) 47 | ); 48 | }, 49 | }, 50 | ]; 51 | 52 | for (const driver of drivers) { 53 | describe.skipIf(driver.enabled === false)( 54 | `drivers: db0 - ${driver.name}`, 55 | async () => { 56 | const db = await driver.getDB(); 57 | 58 | afterAll(async () => { 59 | await db.sql`DROP TABLE IF EXISTS unstorage`; 60 | }); 61 | 62 | testDriver({ 63 | driver: () => db0Driver({ database: db }), 64 | additionalTests: (ctx) => { 65 | it("meta", async () => { 66 | await ctx.storage.setItem("meta:test", "test_data"); 67 | 68 | expect(await ctx.storage.getMeta("meta:test")).toMatchObject({ 69 | birthtime: expect.any(Date), 70 | mtime: expect.any(Date), 71 | }); 72 | }); 73 | }, 74 | }); 75 | } 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /test/drivers/session-storage.test.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | import driver from "../../src/drivers/session-storage.ts"; 4 | import { testDriver } from "./utils.ts"; 5 | 6 | describe("drivers: session-storage", () => { 7 | const jsdom = new JSDOM("", { 8 | url: "http://localhost", 9 | }); 10 | // jsdom.virtualConsole.sendTo(console); 11 | 12 | testDriver({ 13 | driver: driver({ window: jsdom.window as unknown as typeof window }), 14 | additionalTests: (ctx) => { 15 | it("check session storage", async () => { 16 | await ctx.storage.setItem("s1:a", "test_data"); 17 | expect(jsdom.window.sessionStorage.getItem("s1:a")).toBe("test_data"); 18 | }); 19 | it("watch session storage", async () => { 20 | const watcher = vi.fn(); 21 | await ctx.storage.watch(watcher); 22 | 23 | // Emulate 24 | // jsdom.window.sessionStorage.setItem('s1:random_file', 'random') 25 | const ev = jsdom.window.document.createEvent("CustomEvent"); 26 | ev.initEvent("storage", true); 27 | // @ts-ignore 28 | ev.key = "s1:random_file"; 29 | // @ts-ignore 30 | ev.newValue = "random"; 31 | jsdom.window.dispatchEvent(ev); 32 | 33 | expect(watcher).toHaveBeenCalledWith("update", "s1:random_file"); 34 | }); 35 | it("unwatch session storage", async () => { 36 | const watcher = vi.fn(); 37 | const unwatch = await ctx.storage.watch(watcher); 38 | 39 | // Emulate 40 | // jsdom.window.sessionStorage.setItem('s1:random_file', 'random') 41 | const ev = jsdom.window.document.createEvent("CustomEvent"); 42 | ev.initEvent("storage", true); 43 | // @ts-ignore 44 | ev.key = "s1:random_file"; 45 | // @ts-ignore 46 | ev.newValue = "random"; 47 | const ev2 = jsdom.window.document.createEvent("CustomEvent"); 48 | ev2.initEvent("storage", true); 49 | // @ts-ignore 50 | ev2.key = "s1:random_file2"; 51 | // @ts-ignore 52 | ev2.newValue = "random"; 53 | 54 | jsdom.window.dispatchEvent(ev); 55 | 56 | await unwatch(); 57 | 58 | jsdom.window.dispatchEvent(ev2); 59 | 60 | expect(watcher).toHaveBeenCalledWith("update", "s1:random_file"); 61 | expect(watcher).toHaveBeenCalledTimes(1); 62 | }); 63 | }, 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/_utils.ts: -------------------------------------------------------------------------------- 1 | type Awaited = T extends Promise ? Awaited : T; 2 | type Promisified = Promise>; 3 | 4 | export function wrapToPromise(value: T) { 5 | if (!value || typeof (value as any).then !== "function") { 6 | return Promise.resolve(value) as Promisified; 7 | } 8 | return value as unknown as Promisified; 9 | } 10 | 11 | export function asyncCall any>( 12 | function_: T, 13 | ...arguments_: any[] 14 | ): Promisified> { 15 | try { 16 | return wrapToPromise(function_(...arguments_)); 17 | } catch (error) { 18 | return Promise.reject(error); 19 | } 20 | } 21 | 22 | function isPrimitive(value: any) { 23 | const type = typeof value; 24 | return value === null || (type !== "object" && type !== "function"); 25 | } 26 | 27 | function isPureObject(value: any) { 28 | const proto = Object.getPrototypeOf(value); 29 | // eslint-disable-next-line no-prototype-builtins 30 | return !proto || proto.isPrototypeOf(Object); 31 | } 32 | 33 | export function stringify(value: any): string { 34 | if (isPrimitive(value)) { 35 | return String(value); 36 | } 37 | 38 | if (isPureObject(value) || Array.isArray(value)) { 39 | return JSON.stringify(value); 40 | } 41 | 42 | if (typeof value.toJSON === "function") { 43 | return stringify(value.toJSON()); 44 | } 45 | 46 | throw new Error("[unstorage] Cannot stringify value!"); 47 | } 48 | 49 | export const BASE64_PREFIX = "base64:"; 50 | 51 | export function serializeRaw(value: any) { 52 | if (typeof value === "string") { 53 | return value; 54 | } 55 | return BASE64_PREFIX + base64Encode(value); 56 | } 57 | 58 | export function deserializeRaw(value: any) { 59 | if (typeof value !== "string") { 60 | // Return non-strings as-is 61 | return value; 62 | } 63 | if (!value.startsWith(BASE64_PREFIX)) { 64 | // Return unknown strings as-is 65 | return value; 66 | } 67 | return base64Decode(value.slice(BASE64_PREFIX.length)); 68 | } 69 | 70 | function base64Decode(input: string) { 71 | if (globalThis.Buffer) { 72 | return Buffer.from(input, "base64"); 73 | } 74 | return Uint8Array.from( 75 | globalThis.atob(input), 76 | (c) => c.codePointAt(0) as number 77 | ); 78 | } 79 | 80 | function base64Encode(input: Uint8Array) { 81 | if (globalThis.Buffer) { 82 | return Buffer.from(input).toString("base64"); 83 | } 84 | return globalThis.btoa(String.fromCodePoint(...input)); 85 | } 86 | -------------------------------------------------------------------------------- /src/drivers/fs-lite.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, promises as fsp, Stats } from "node:fs"; 2 | import { resolve, join } from "node:path"; 3 | import { 4 | createError, 5 | createRequiredError, 6 | defineDriver, 7 | } from "./utils/index.ts"; 8 | import { 9 | readFile, 10 | writeFile, 11 | readdirRecursive, 12 | rmRecursive, 13 | unlink, 14 | } from "./utils/node-fs.ts"; 15 | 16 | export interface FSStorageOptions { 17 | base?: string; 18 | ignore?: (path: string) => boolean; 19 | readOnly?: boolean; 20 | noClear?: boolean; 21 | } 22 | 23 | const PATH_TRAVERSE_RE = /\.\.:|\.\.$/; 24 | 25 | const DRIVER_NAME = "fs-lite"; 26 | 27 | export default defineDriver((opts: FSStorageOptions = {}) => { 28 | if (!opts.base) { 29 | throw createRequiredError(DRIVER_NAME, "base"); 30 | } 31 | 32 | opts.base = resolve(opts.base); 33 | const r = (key: string) => { 34 | if (PATH_TRAVERSE_RE.test(key)) { 35 | throw createError( 36 | DRIVER_NAME, 37 | `Invalid key: ${JSON.stringify(key)}. It should not contain .. segments` 38 | ); 39 | } 40 | const resolved = join(opts.base!, key.replace(/:/g, "/")); 41 | return resolved; 42 | }; 43 | 44 | return { 45 | name: DRIVER_NAME, 46 | options: opts, 47 | flags: { 48 | maxDepth: true, 49 | }, 50 | hasItem(key) { 51 | return existsSync(r(key)); 52 | }, 53 | getItem(key) { 54 | return readFile(r(key), "utf8"); 55 | }, 56 | getItemRaw(key) { 57 | return readFile(r(key)); 58 | }, 59 | async getMeta(key) { 60 | const { atime, mtime, size, birthtime, ctime } = await fsp 61 | .stat(r(key)) 62 | .catch(() => ({}) as Stats); 63 | return { atime, mtime, size, birthtime, ctime }; 64 | }, 65 | setItem(key, value) { 66 | if (opts.readOnly) { 67 | return; 68 | } 69 | return writeFile(r(key), value, "utf8"); 70 | }, 71 | setItemRaw(key, value) { 72 | if (opts.readOnly) { 73 | return; 74 | } 75 | return writeFile(r(key), value); 76 | }, 77 | removeItem(key) { 78 | if (opts.readOnly) { 79 | return; 80 | } 81 | return unlink(r(key)); 82 | }, 83 | getKeys(_base, topts) { 84 | return readdirRecursive(r("."), opts.ignore, topts?.maxDepth); 85 | }, 86 | async clear() { 87 | if (opts.readOnly || opts.noClear) { 88 | return; 89 | } 90 | await rmRecursive(r(".")); 91 | }, 92 | }; 93 | }); 94 | -------------------------------------------------------------------------------- /src/drivers/overlay.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver } from "./utils/index.ts"; 2 | import type { Driver } from "../types.ts"; 3 | import { normalizeKey } from "./utils/index.ts"; 4 | 5 | export interface OverlayStorageOptions { 6 | layers: Driver[]; 7 | } 8 | 9 | const OVERLAY_REMOVED = "__OVERLAY_REMOVED__"; 10 | 11 | const DRIVER_NAME = "overlay"; 12 | 13 | export default defineDriver((options: OverlayStorageOptions) => { 14 | return { 15 | name: DRIVER_NAME, 16 | options: options, 17 | async hasItem(key, opts) { 18 | for (const layer of options.layers) { 19 | if (await layer.hasItem(key, opts)) { 20 | if ( 21 | layer === options.layers[0] && 22 | (await options.layers[0]?.getItem(key)) === OVERLAY_REMOVED 23 | ) { 24 | return false; 25 | } 26 | return true; 27 | } 28 | } 29 | return false; 30 | }, 31 | async getItem(key) { 32 | for (const layer of options.layers) { 33 | const value = await layer.getItem(key); 34 | if (value === OVERLAY_REMOVED) { 35 | return null; 36 | } 37 | if (value !== null) { 38 | return value; 39 | } 40 | } 41 | return null; 42 | }, 43 | // TODO: Support native meta 44 | // async getMeta (key) {}, 45 | async setItem(key, value, opts) { 46 | await options.layers[0]?.setItem?.(key, value, opts); 47 | }, 48 | async removeItem(key, opts) { 49 | await options.layers[0]?.setItem?.(key, OVERLAY_REMOVED, opts); 50 | }, 51 | async getKeys(base, opts) { 52 | const allKeys = await Promise.all( 53 | options.layers.map(async (layer) => { 54 | const keys = await layer.getKeys(base, opts); 55 | return keys.map((key) => normalizeKey(key)); 56 | }) 57 | ); 58 | const uniqueKeys = [...new Set(allKeys.flat())]; 59 | const existingKeys = await Promise.all( 60 | uniqueKeys.map(async (key) => { 61 | if ((await options.layers[0]?.getItem(key)) === OVERLAY_REMOVED) { 62 | return false; 63 | } 64 | return key; 65 | }) 66 | ); 67 | return existingKeys.filter(Boolean) as string[]; 68 | }, 69 | async dispose() { 70 | // TODO: Graceful error handling 71 | await Promise.all( 72 | options.layers.map(async (layer) => { 73 | if (layer.dispose) { 74 | await layer.dispose(); 75 | } 76 | }) 77 | ); 78 | }, 79 | }; 80 | }); 81 | -------------------------------------------------------------------------------- /scripts/gen-drivers.ts: -------------------------------------------------------------------------------- 1 | import { readFile, readdir, writeFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import { findTypeExports } from "mlly"; 5 | import { camelCase, upperFirst } from "scule"; 6 | 7 | const driversDir = fileURLToPath(new URL("../src/drivers", import.meta.url)); 8 | 9 | const driversMetaFile = fileURLToPath( 10 | new URL("../src/_drivers.ts", import.meta.url) 11 | ); 12 | 13 | const driverEntries: string[] = ( 14 | await readdir(driversDir, { withFileTypes: true }) 15 | ) 16 | .filter((entry) => entry.isFile()) 17 | .map((entry) => entry.name); 18 | 19 | const drivers: { 20 | name: string; 21 | safeName: string; 22 | names: string[]; 23 | subpath: string; 24 | optionsTExport?: string; 25 | optionsTName?: string; 26 | }[] = []; 27 | 28 | for (const entry of driverEntries) { 29 | const name = entry.replace(/\.ts$/, ""); 30 | const subpath = `unstorage/drivers/${name}`; 31 | const fullPath = join(driversDir, `${name}.ts`); 32 | 33 | const contents = await readFile(fullPath, "utf8"); 34 | const optionsTExport = findTypeExports(contents).find((type) => 35 | type.name?.endsWith("Options") 36 | )?.name; 37 | 38 | const safeName = camelCase(name) 39 | .replace(/kv/i, "KV") 40 | .replace("localStorage", "localstorage"); 41 | 42 | const names = [...new Set([name, safeName])]; 43 | 44 | const optionsTName = upperFirst(safeName) + "Options"; 45 | 46 | drivers.push({ 47 | name, 48 | safeName, 49 | names, 50 | subpath, 51 | optionsTExport, 52 | optionsTName, 53 | }); 54 | } 55 | 56 | const genCode = /* ts */ `// Auto-generated using scripts/gen-drivers. 57 | // Do not manually edit! 58 | 59 | ${drivers 60 | .filter((d) => d.optionsTExport) 61 | .map( 62 | (d) => 63 | /* ts */ `import type { ${d.optionsTExport} as ${d.optionsTName} } from "${d.subpath}";` 64 | ) 65 | .join("\n")} 66 | 67 | export type BuiltinDriverName = ${drivers.flatMap((d) => d.names.map((name) => `"${name}"`)).join(" | ")}; 68 | 69 | export type BuiltinDriverOptions = { 70 | ${drivers 71 | .filter((d) => d.optionsTExport) 72 | .flatMap((d) => d.names.map((name) => `"${name}": ${d.optionsTName};`)) 73 | .join("\n ")} 74 | }; 75 | 76 | export const builtinDrivers = { 77 | ${drivers.flatMap((d) => d.names.map((name) => `"${name}": "${d.subpath}"`)).join(",\n ")}, 78 | } as const; 79 | `; 80 | 81 | await writeFile(driversMetaFile, genCode, "utf8"); 82 | console.log("Generated drivers metadata file to", driversMetaFile); 83 | -------------------------------------------------------------------------------- /docs/.config/docs.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://unpkg.com/undocs/schema/config.json 2 | name: unstorage 3 | shortDescription: Universal Key-Value. 4 | description: Unified key-value storage API with conventional features and 20+ built-in drivers. 5 | github: unjs/unstorage 6 | themeColor: amber 7 | url: https://unstorage.unjs.io 8 | socials: 9 | twitter: "https://twitter.com/unjsio" 10 | bluesky: "https://bsky.app/profile/unjs.io" 11 | sponsors: 12 | api: https://sponsors.pi0.io/sponsors.json 13 | redirects: 14 | "/usage": "/getting-started/usage" 15 | "/utils": "/getting-started/utils" 16 | "/http-server": "/getting-started/http-server" 17 | "/custom-driver": "/getting-started/custom-driver" 18 | "/drivers/azure-app-configuration": "/divers/azure" 19 | "/drivers/azure-cosmos": "/divers/azure" 20 | "/drivers/azure-key-vault": "/divers/azure" 21 | "/drivers/azure-storage-block": "/divers/azure" 22 | "/drivers/azure-storage-table": "/divers/azure" 23 | "/drivers/cloudflare-kv-binding": "/drivers/cloudflare" 24 | "/drivers/cloudflare-kv-http": "/drivers/cloudflare" 25 | "/drivers/cloudflare-r2-binding": "/drivers/cloudflare" 26 | "/drivers/vercel-kv": "/drivers/vercel" 27 | "/drivers/netlify-blobs": "/drivers/netlify" 28 | "/drivers/localstorage": "/drivers/browser" 29 | "/drivers/indexedb": "/drivers/browser" 30 | "/drivers/session-storage": "/drivers/browser" 31 | landing: 32 | contributors: true 33 | featuresTitle: A simple, small, and fast key-value storage library for JavaScript. 34 | features: 35 | - title: "Runtime Agnostic" 36 | description: "Your code will work on any JavaScript runtime including Node.js, Bun, Deno and Workers." 37 | icon: "i-material-symbols-lock-open-right-outline-rounded" 38 | - title: "Built-in drivers" 39 | description: "Unstorage is shipped with 20+ built-in drivers for different platforms: Memory (default), FS, Redis, Memory, MongoDB, CloudFlare, GitHub, etc." 40 | icon: "i-material-symbols-usb" 41 | - title: "Snapshots" 42 | description: "Expand your server and add capabilities. Your codebase will scale with your project." 43 | icon: "i-material-symbols-add-a-photo-outline" 44 | - title: "Multi Storages" 45 | description: "Unix-style driver mounting to combine storages on different mounts." 46 | icon: "i-material-symbols-view-list-outline" 47 | - title: "JSON friendly" 48 | description: "Unstorage automatically serializes and deserializes JSON values." 49 | icon: "i-material-symbols-magic-button" 50 | - title: "Binary Support" 51 | description: "Store binary and raw data like images, videos, audio files, etc." 52 | icon: "i-material-symbols-audio-file" 53 | -------------------------------------------------------------------------------- /test/drivers/localstorage.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import driver from "../../src/drivers/localstorage.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import { JSDOM } from "jsdom"; 5 | 6 | describe("drivers: localstorage", () => { 7 | const jsdom = new JSDOM("", { 8 | url: "http://localhost", 9 | }); 10 | // jsdom.virtualConsole.sendTo(console); 11 | 12 | jsdom.window.localStorage.setItem("__external_key__", "unrelated_data"); 13 | 14 | testDriver({ 15 | driver: driver({ 16 | window: jsdom.window as unknown as typeof window, 17 | base: "test", 18 | }), 19 | additionalTests: (ctx) => { 20 | it("check localstorage", async () => { 21 | await ctx.storage.setItem("s1:a", "test_data"); 22 | expect(jsdom.window.localStorage.getItem("test:s1:a")).toBe( 23 | "test_data" 24 | ); 25 | await ctx.driver.clear!("", {}); 26 | expect(jsdom.window.localStorage.getItem("__external_key__")).toBe( 27 | "unrelated_data" 28 | ); 29 | }); 30 | it("watch localstorage", async () => { 31 | const watcher = vi.fn(); 32 | await ctx.storage.watch(watcher); 33 | 34 | // Emulate 35 | // jsdom.window.localStorage.setItem('s1:random_file', 'random') 36 | const ev = jsdom.window.document.createEvent("CustomEvent"); 37 | ev.initEvent("storage", true); 38 | // @ts-ignore 39 | ev.key = "s1:random_file"; 40 | // @ts-ignore 41 | ev.newValue = "random"; 42 | jsdom.window.dispatchEvent(ev); 43 | 44 | expect(watcher).toHaveBeenCalledWith("update", "s1:random_file"); 45 | }); 46 | it("unwatch localstorage", async () => { 47 | const watcher = vi.fn(); 48 | const unwatch = await ctx.storage.watch(watcher); 49 | 50 | // Emulate 51 | // jsdom.window.localStorage.setItem('s1:random_file', 'random') 52 | const ev = jsdom.window.document.createEvent("CustomEvent"); 53 | ev.initEvent("storage", true); 54 | // @ts-ignore 55 | ev.key = "s1:random_file"; 56 | // @ts-ignore 57 | ev.newValue = "random"; 58 | const ev2 = jsdom.window.document.createEvent("CustomEvent"); 59 | ev2.initEvent("storage", true); 60 | // @ts-ignore 61 | ev2.key = "s1:random_file2"; 62 | // @ts-ignore 63 | ev2.newValue = "random"; 64 | 65 | jsdom.window.dispatchEvent(ev); 66 | 67 | await unwatch(); 68 | 69 | jsdom.window.dispatchEvent(ev2); 70 | 71 | expect(watcher).toHaveBeenCalledWith("update", "s1:random_file"); 72 | expect(watcher).toHaveBeenCalledTimes(1); 73 | }); 74 | }, 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/drivers/fs-lite.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { resolve } from "node:path"; 3 | import { readFile } from "../../src/drivers/utils/node-fs.ts"; 4 | import { testDriver } from "./utils.ts"; 5 | import driver from "../../src/drivers/fs-lite.ts"; 6 | 7 | describe("drivers: fs-lite", () => { 8 | const dir = resolve(__dirname, "tmp/fs-lite"); 9 | 10 | testDriver({ 11 | driver: driver({ base: dir }), 12 | additionalTests(ctx) { 13 | it("check filesystem", async () => { 14 | await ctx.storage.setItem("s1:a", "test_data"); 15 | expect(await readFile(resolve(dir, "s1/a"), "utf8")).toBe("test_data"); 16 | }); 17 | it("native meta", async () => { 18 | await ctx.storage.setItem("s1:a", "test_data"); 19 | const meta = await ctx.storage.getMeta("/s1/a"); 20 | expect(meta.atime?.constructor.name).toBe("Date"); 21 | expect(meta.mtime?.constructor.name).toBe("Date"); 22 | expect(meta.size).toBeGreaterThan(0); 23 | }); 24 | 25 | const invalidKeys = ["../foobar", "..:foobar", "../", "..:", ".."]; 26 | for (const key of invalidKeys) { 27 | it("disallow path travesal: ", async () => { 28 | await expect(ctx.storage.getItem(key)).rejects.toThrow("Invalid key"); 29 | }); 30 | } 31 | 32 | it("allow double dots in filename: ", async () => { 33 | await ctx.storage.setItem("s1/te..st..js", "ok"); 34 | expect(await ctx.storage.getItem("s1/te..st..js")).toBe("ok"); 35 | }); 36 | 37 | it("natively supports maxDepth in getKeys", async () => { 38 | await ctx.storage.setItem("file0.md", "boop"); 39 | await ctx.storage.setItem("depth-test/file1.md", "boop"); 40 | await ctx.storage.setItem("depth-test/depth0/file2.md", "boop"); 41 | await ctx.storage.setItem("depth-test/depth0/depth1/file3.md", "boop"); 42 | await ctx.storage.setItem("depth-test/depth0/depth1/file4.md", "boop"); 43 | 44 | expect( 45 | ( 46 | await ctx.driver.getKeys("", { 47 | maxDepth: 0, 48 | }) 49 | ).sort() 50 | ).toMatchObject(["file0.md"]); 51 | expect( 52 | ( 53 | await ctx.driver.getKeys("", { 54 | maxDepth: 1, 55 | }) 56 | ).sort() 57 | ).toMatchObject(["depth-test/file1.md", "file0.md"]); 58 | expect( 59 | ( 60 | await ctx.driver.getKeys("", { 61 | maxDepth: 2, 62 | }) 63 | ).sort() 64 | ).toMatchObject([ 65 | "depth-test/depth0/file2.md", 66 | "depth-test/file1.md", 67 | "file0.md", 68 | ]); 69 | }); 70 | }, 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/drivers/fs.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { resolve } from "node:path"; 3 | import { readFile, writeFile } from "../../src/drivers/utils/node-fs.ts"; 4 | import { testDriver } from "./utils.ts"; 5 | import driver from "../../src/drivers/fs.ts"; 6 | 7 | describe("drivers: fs", () => { 8 | const dir = resolve(__dirname, "tmp/fs"); 9 | 10 | testDriver({ 11 | driver: driver({ base: dir }), 12 | additionalTests(ctx) { 13 | it("check filesystem", async () => { 14 | await ctx.storage.setItem("s1:a", "test_data"); 15 | expect(await readFile(resolve(dir, "s1/a"), "utf8")).toBe("test_data"); 16 | }); 17 | it("native meta", async () => { 18 | await ctx.storage.setItem("s1:a", "test_data"); 19 | const meta = await ctx.storage.getMeta("/s1/a"); 20 | expect(meta.atime?.constructor.name).toBe("Date"); 21 | expect(meta.mtime?.constructor.name).toBe("Date"); 22 | expect(meta.size).toBeGreaterThan(0); 23 | }); 24 | it("watch filesystem", async () => { 25 | const watcher = vi.fn(); 26 | await ctx.storage.watch(watcher); 27 | await writeFile(resolve(dir, "s1/random_file"), "random", "utf8"); 28 | await new Promise((resolve) => setTimeout(resolve, 500)); 29 | expect(watcher).toHaveBeenCalledWith("update", "s1:random_file"); 30 | }); 31 | 32 | const invalidKeys = ["../foobar", "..:foobar", "../", "..:", ".."]; 33 | for (const key of invalidKeys) { 34 | it("disallow path travesal: ", async () => { 35 | await expect(ctx.storage.getItem(key)).rejects.toThrow("Invalid key"); 36 | }); 37 | } 38 | 39 | it("allow double dots in filename: ", async () => { 40 | await ctx.storage.setItem("s1/te..st..js", "ok"); 41 | expect(await ctx.storage.getItem("s1/te..st..js")).toBe("ok"); 42 | }); 43 | 44 | it("natively supports maxDepth in getKeys", async () => { 45 | await ctx.storage.setItem("depth-test/file0.md", "boop"); 46 | await ctx.storage.setItem("depth-test/depth0/file1.md", "boop"); 47 | await ctx.storage.setItem("depth-test/depth0/depth1/file2.md", "boop"); 48 | await ctx.storage.setItem("depth-test/depth0/depth1/file3.md", "boop"); 49 | 50 | expect( 51 | ( 52 | await ctx.driver.getKeys("", { 53 | maxDepth: 1, 54 | }) 55 | ).sort() 56 | ).toMatchObject(["depth-test/file0.md"]); 57 | 58 | expect( 59 | ( 60 | await ctx.driver.getKeys("", { 61 | maxDepth: 2, 62 | }) 63 | ).sort() 64 | ).toMatchObject(["depth-test/depth0/file1.md", "depth-test/file0.md"]); 65 | }); 66 | }, 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/drivers/utils/node-fs.ts: -------------------------------------------------------------------------------- 1 | import { Dirent, existsSync, promises as fsPromises } from "node:fs"; 2 | import { resolve, dirname } from "node:path"; 3 | 4 | function ignoreNotfound(err: any) { 5 | return err.code === "ENOENT" || err.code === "EISDIR" ? null : err; 6 | } 7 | 8 | function ignoreExists(err: any) { 9 | return err.code === "EEXIST" ? null : err; 10 | } 11 | 12 | type WriteFileData = Parameters[1]; 13 | export async function writeFile( 14 | path: string, 15 | data: WriteFileData, 16 | encoding?: BufferEncoding 17 | ) { 18 | await ensuredir(dirname(path)); 19 | return fsPromises.writeFile(path, data, encoding); 20 | } 21 | 22 | export function readFile(path: string, encoding?: BufferEncoding) { 23 | return fsPromises.readFile(path, encoding).catch(ignoreNotfound); 24 | } 25 | 26 | export function stat(path: string) { 27 | return fsPromises.stat(path).catch(ignoreNotfound); 28 | } 29 | 30 | export function unlink(path: string) { 31 | return fsPromises.unlink(path).catch(ignoreNotfound); 32 | } 33 | 34 | export function readdir(dir: string): Promise { 35 | return fsPromises 36 | .readdir(dir, { withFileTypes: true }) 37 | .catch(ignoreNotfound) 38 | .then((r) => r || []); 39 | } 40 | 41 | export async function ensuredir(dir: string) { 42 | if (existsSync(dir)) { 43 | return; 44 | } 45 | await ensuredir(dirname(dir)).catch(ignoreExists); 46 | await fsPromises.mkdir(dir).catch(ignoreExists); 47 | } 48 | 49 | export async function readdirRecursive( 50 | dir: string, 51 | ignore?: (p: string) => boolean, 52 | maxDepth?: number 53 | ) { 54 | if (ignore && ignore(dir)) { 55 | return []; 56 | } 57 | const entries: Dirent[] = await readdir(dir); 58 | const files: string[] = []; 59 | await Promise.all( 60 | entries.map(async (entry) => { 61 | const entryPath = resolve(dir, entry.name); 62 | if (entry.isDirectory()) { 63 | if (maxDepth === undefined || maxDepth > 0) { 64 | const dirFiles = await readdirRecursive( 65 | entryPath, 66 | ignore, 67 | maxDepth === undefined ? undefined : maxDepth - 1 68 | ); 69 | files.push(...dirFiles.map((f) => entry.name + "/" + f)); 70 | } 71 | } else { 72 | if (!(ignore && ignore(entry.name))) { 73 | files.push(entry.name); 74 | } 75 | } 76 | }) 77 | ); 78 | return files; 79 | } 80 | 81 | export async function rmRecursive(dir: string) { 82 | const entries = await readdir(dir); 83 | await Promise.all( 84 | entries.map((entry) => { 85 | const entryPath = resolve(dir, entry.name); 86 | if (entry.isDirectory()) { 87 | return rmRecursive(entryPath).then(() => fsPromises.rmdir(entryPath)); 88 | } else { 89 | return fsPromises.unlink(entryPath); 90 | } 91 | }) 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/drivers/cloudflare-kv-binding.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineDriver, joinKeys } from "./utils/index.ts"; 3 | import { getKVBinding } from "./utils/cloudflare.ts"; 4 | export interface KVOptions { 5 | binding?: string | KVNamespace; 6 | 7 | /** Adds prefix to all stored keys */ 8 | base?: string; 9 | 10 | /** 11 | * The minimum time-to-live (ttl) for setItem in seconds. 12 | * The default is 60 seconds as per Cloudflare's [documentation](https://developers.cloudflare.com/kv/api/write-key-value-pairs/). 13 | */ 14 | minTTL?: number; 15 | } 16 | 17 | // https://developers.cloudflare.com/workers/runtime-apis/kv 18 | 19 | const DRIVER_NAME = "cloudflare-kv-binding"; 20 | 21 | export default defineDriver((opts: KVOptions) => { 22 | const r = (key: string = "") => (opts.base ? joinKeys(opts.base, key) : key); 23 | 24 | async function getKeys(base: string = "") { 25 | base = r(base); 26 | const binding = getKVBinding(opts.binding); 27 | const keys: { name: string }[] = []; 28 | let cursor: string | undefined = undefined; 29 | do { 30 | const kvList = await binding.list({ prefix: base || undefined, cursor }); 31 | 32 | keys.push(...kvList.keys); 33 | cursor = (kvList.list_complete ? undefined : kvList.cursor) as 34 | | string 35 | | undefined; 36 | } while (cursor); 37 | 38 | return keys.map((key) => key.name); 39 | } 40 | 41 | return { 42 | name: DRIVER_NAME, 43 | options: opts, 44 | getInstance: () => getKVBinding(opts.binding), 45 | async hasItem(key) { 46 | key = r(key); 47 | const binding = getKVBinding(opts.binding); 48 | return (await binding.get(key)) !== null; 49 | }, 50 | getItem(key) { 51 | key = r(key); 52 | const binding = getKVBinding(opts.binding); 53 | return binding.get(key); 54 | }, 55 | setItem(key, value, topts) { 56 | key = r(key); 57 | const binding = getKVBinding(opts.binding); 58 | return binding.put( 59 | key, 60 | value, 61 | topts 62 | ? { 63 | expirationTtl: topts?.ttl 64 | ? Math.max(topts.ttl, opts.minTTL ?? 60) 65 | : undefined, 66 | ...topts, 67 | } 68 | : undefined 69 | ); 70 | }, 71 | removeItem(key) { 72 | key = r(key); 73 | const binding = getKVBinding(opts.binding); 74 | return binding.delete(key); 75 | }, 76 | getKeys(base) { 77 | return getKeys(base).then((keys) => 78 | keys.map((key) => (opts.base ? key.slice(opts.base.length) : key)) 79 | ); 80 | }, 81 | async clear(base) { 82 | const binding = getKVBinding(opts.binding); 83 | const keys = await getKeys(base); 84 | await Promise.all(keys.map((key) => binding.delete(key))); 85 | }, 86 | }; 87 | }); 88 | -------------------------------------------------------------------------------- /docs/2.drivers/redis.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: simple-icons:redis 3 | --- 4 | 5 | # Redis 6 | 7 | > Store data in a Redis. 8 | 9 | ## Usage 10 | 11 | **Driver name:** `redis` 12 | 13 | ::read-more{to="https://redis.com"} 14 | Learn more about Redis. 15 | :: 16 | 17 | ::note 18 | Unstorage uses [`ioredis`](https://github.com/redis/ioredis) internally to connect to Redis. 19 | :: 20 | 21 | To use it, you will need to install `ioredis` in your project: 22 | 23 | :pm-install{name="ioredis"} 24 | 25 | Usage with single Redis instance: 26 | 27 | ```js 28 | import { createStorage } from "unstorage"; 29 | import redisDriver from "unstorage/drivers/redis"; 30 | 31 | const storage = createStorage({ 32 | driver: redisDriver({ 33 | base: "unstorage", 34 | host: 'HOSTNAME', 35 | tls: true as any, 36 | port: 6380, 37 | password: 'REDIS_PASSWORD' 38 | }), 39 | }); 40 | ``` 41 | 42 | Usage with a Redis cluster (e.g. AWS ElastiCache or Azure Redis Cache): 43 | 44 | ⚠️ If you connect to a cluster, when running commands that operate over multiple keys, all keys must be part of the same hashslot. Otherwise you may encounter the Redis error `CROSSSLOT Keys in request don't hash to the same slot`. You should use [`hashtags`](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags) to control how keys are slotted. If you want all keys to hash to the same slot, you can include the hashtag in the base prefix by wrapping it in curly braces. Read more about [Clustering Best Practices](https://redis.io/blog/redis-clustering-best-practices-with-keys/). 45 | 46 | ```js 47 | const storage = createStorage({ 48 | driver: redisDriver({ 49 | base: "{unstorage}", 50 | cluster: [ 51 | { 52 | port: 6380, 53 | host: "HOSTNAME", 54 | }, 55 | ], 56 | clusterOptions: { 57 | redisOptions: { 58 | tls: { servername: "HOSTNAME" }, 59 | password: "REDIS_PASSWORD", 60 | }, 61 | }, 62 | }), 63 | }); 64 | ``` 65 | 66 | **Options:** 67 | 68 | - `base`: Optional prefix to use for all keys. Can be used for namespacing. Has to be used as a hashtag prefix for redis cluster mode. 69 | - `url`: Url to use for connecting to redis. Takes precedence over `host` option. Has the format `redis://:@:` 70 | - `cluster`: List of redis nodes to use for cluster mode. Takes precedence over `url` and `host` options. 71 | - `clusterOptions`: Options to use for cluster mode. 72 | - `ttl`: Default TTL for all items in **seconds**. 73 | - `scanCount`: How many keys to scan at once ([redis documentation](https://redis.io/docs/latest/commands/scan/#the-count-option)). 74 | - `preConnect`: Whether to initialize the redis instance immediately. Otherwise, it will be initialized on the first read/write call. Default: `false`. 75 | 76 | See [ioredis](https://github.com/redis/ioredis/blob/master/API.md#new-redisport-host-options) for all available options. 77 | 78 | **Transaction options:** 79 | 80 | - `ttl`: Supported for `setItem(key, value, { ttl: number /* seconds */ })` 81 | -------------------------------------------------------------------------------- /src/drivers/localstorage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRequiredError, 3 | defineDriver, 4 | normalizeKey, 5 | } from "./utils/index.ts"; 6 | 7 | export interface LocalStorageOptions { 8 | base?: string; 9 | window?: typeof window; 10 | windowKey?: "localStorage" | "sessionStorage"; 11 | storage?: typeof window.localStorage | typeof window.sessionStorage; 12 | /** @deprecated use `storage` option */ 13 | sessionStorage?: typeof window.sessionStorage; 14 | /** @deprecated use `storage` option */ 15 | localStorage?: typeof window.localStorage; 16 | } 17 | 18 | const DRIVER_NAME = "localstorage"; 19 | 20 | export default defineDriver((opts: LocalStorageOptions = {}) => { 21 | const storage: typeof window.localStorage | typeof window.sessionStorage = 22 | opts.storage || 23 | opts.localStorage || 24 | opts.sessionStorage || 25 | (opts.window || globalThis.window)?.[opts.windowKey || "localStorage"]; 26 | 27 | if (!storage) { 28 | throw createRequiredError(DRIVER_NAME, "localStorage"); 29 | } 30 | 31 | const base = opts.base ? normalizeKey(opts.base) : ""; 32 | const r = (key: string) => (base ? `${base}:` : "") + key; 33 | 34 | let _storageListener: undefined | ((ev: StorageEvent) => void); 35 | const _unwatch = () => { 36 | if (_storageListener) { 37 | opts.window?.removeEventListener("storage", _storageListener); 38 | } 39 | _storageListener = undefined; 40 | }; 41 | 42 | return { 43 | name: DRIVER_NAME, 44 | options: opts, 45 | getInstance: () => storage!, 46 | hasItem(key) { 47 | return Object.prototype.hasOwnProperty.call(storage!, r(key)); 48 | }, 49 | getItem(key) { 50 | return storage!.getItem(r(key)); 51 | }, 52 | setItem(key, value) { 53 | return storage!.setItem(r(key), value); 54 | }, 55 | removeItem(key) { 56 | return storage!.removeItem(r(key)); 57 | }, 58 | getKeys() { 59 | const allKeys = Object.keys(storage!); 60 | return base 61 | ? allKeys 62 | .filter((key) => key.startsWith(`${base}:`)) 63 | .map((key) => key.slice(base.length + 1)) 64 | : allKeys; 65 | }, 66 | clear(prefix) { 67 | const _base = [base, prefix].filter(Boolean).join(":"); 68 | if (_base) { 69 | for (const key of Object.keys(storage!)) { 70 | if (key.startsWith(`${_base}:`)) { 71 | storage?.removeItem(key); 72 | } 73 | } 74 | } else { 75 | storage!.clear(); 76 | } 77 | }, 78 | dispose() { 79 | if (opts.window && _storageListener) { 80 | opts.window.removeEventListener("storage", _storageListener); 81 | } 82 | }, 83 | watch(callback) { 84 | if (!opts.window) { 85 | return _unwatch; 86 | } 87 | _storageListener = (ev: StorageEvent) => { 88 | if (ev.key) { 89 | callback(ev.newValue ? "update" : "remove", ev.key); 90 | } 91 | }; 92 | opts.window.addEventListener("storage", _storageListener); 93 | return _unwatch; 94 | }, 95 | }; 96 | }); 97 | -------------------------------------------------------------------------------- /docs/2.drivers/deno.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: simple-icons:deno 3 | --- 4 | 5 | # Deno KV 6 | 7 | > Store data in Deno KV 8 | 9 | ::note{to="https://deno.com/kv"} 10 | Learn more about Deno KV. 11 | :: 12 | 13 | ## Usage (Deno) 14 | 15 | **Driver name:** `deno-kv` 16 | 17 | ::important 18 | `deno-kv` driver requires [Deno deploy](https://docs.deno.com/deploy/kv/manual/on_deploy/) or [Deno runtime](https://docs.deno.com/runtime/) with `--unstable-kv` CLI flag. See [Node.js](#usage-nodejs) section for other runtimes. 19 | :: 20 | 21 | ::note 22 | The driver automatically maps Unstorage keys to Deno. For example, `"test:key"` key will be mapped to `["test", "key"]` and vice versa. 23 | :: 24 | 25 | ```js 26 | import { createStorage } from "unstorage"; 27 | import denoKVdriver from "unstorage/drivers/deno-kv"; 28 | 29 | const storage = createStorage({ 30 | driver: denoKVdriver({ 31 | // path: ":memory:", 32 | // base: "", 33 | // ttl: 60, // in seconds 34 | }), 35 | }); 36 | ``` 37 | 38 | **Options:** 39 | 40 | - `path`: (optional) File system path to where you'd like to store your database, otherwise one will be created for you based on the current working directory of your script by Deno. You can pass `:memory:` for testing. 41 | - `base`: (optional) Prefix key added to all operations. 42 | - `openKV`: (advanced) Custom method that returns a Deno KV instance. 43 | - `ttl`: (optional) Default TTL for all items in seconds. 44 | 45 | **Per-call options:** 46 | 47 | - `ttl`: Add TTL (in seconds) for this `setItem` call. 48 | 49 | ::note 50 | Expiration is not strictly enforced by Deno: keys may persist after their expire time. For strict expiry, store the timestamp in your value and check it after retrieval. 51 | See [Deno KV Key Expiration](https://docs.deno.com/deploy/kv/manual/key_expiration/) for more information. 52 | :: 53 | 54 | ## Usage (Node.js) 55 | 56 | **Driver name:** `deno-kv-node` 57 | 58 | Deno provides [`@deno/kv`](https://www.npmjs.com/package/@deno/kv) npm package, A Deno KV client library optimized for Node.js. 59 | 60 | - Access [Deno Deploy](https://deno.com/deploy) remote databases (or any 61 | endpoint implementing the open 62 | [KV Connect](https://github.com/denoland/denokv/blob/main/proto/kv-connect.md) 63 | protocol) on Node 18+. 64 | - Create local KV databases backed by 65 | [SQLite](https://www.sqlite.org/index.html), using optimized native 66 | [NAPI](https://nodejs.org/docs/latest-v18.x/api/n-api.html) packages for 67 | Node - compatible with databases created by Deno itself. 68 | - Create ephemeral in-memory KV instances backed by SQLite memory files or by a 69 | lightweight JS-only implementation for testing. 70 | 71 | Install `@deno/kv` peer dependency: 72 | 73 | :pm-install{name="@deno/kv"} 74 | 75 | ```js 76 | import { createStorage } from "unstorage"; 77 | import denoKVNodedriver from "unstorage/drivers/deno-kv-node"; 78 | 79 | const storage = createStorage({ 80 | driver: denoKVNodedriver({ 81 | // path: ":memory:", 82 | // base: "", 83 | }), 84 | }); 85 | ``` 86 | 87 | **Options:** 88 | 89 | - `path`: (same as `deno-kv`) 90 | - `base`: (same as `deno-kv`) 91 | - `openKvOptions`: Check [docs](https://www.npmjs.com/package/@deno/kv#api) for available options. 92 | - `ttl`: (same as `deno-kv`) 93 | -------------------------------------------------------------------------------- /test/storage.test-d.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expectTypeOf } from "vitest"; 2 | import { createStorage, prefixStorage } from "../src/index.ts"; 3 | import type { Storage, StorageValue } from "../src/index.ts"; 4 | 5 | describe("types", () => { 6 | it("default types for storage", async () => { 7 | const storage = createStorage(); 8 | 9 | expectTypeOf( 10 | await storage.getItem("foo") 11 | ).toEqualTypeOf(); 12 | 13 | expectTypeOf(await storage.getItem("foo")).toEqualTypeOf< 14 | boolean | null 15 | >(); 16 | 17 | expectTypeOf( 18 | await storage.getItem<{ hello: string }>("foo") 19 | ).toEqualTypeOf<{ hello: string } | null>(); 20 | 21 | await storage.setItem("foo", "str"); 22 | await storage.set("bar", 1); 23 | await storage.removeItem("foo"); 24 | await storage.remove("bar"); 25 | await storage.del("baz"); 26 | }); 27 | 28 | it("indexed types for storage", async () => { 29 | const storage = createStorage(); 30 | 31 | expectTypeOf(await storage.getItem("foo")).toEqualTypeOf(); 32 | 33 | await storage.setItem("foo", "str"); 34 | // @ts-expect-error should be a string 35 | await storage.set("bar", 1); 36 | 37 | await storage.removeItem("foo"); 38 | await storage.remove("bar"); 39 | await storage.del("baz"); 40 | }); 41 | 42 | it("namespaced types for storage", async () => { 43 | type TestObjType = { 44 | a: number; 45 | b: boolean; 46 | }; 47 | type MyStorage = { 48 | items: { 49 | foo: string; 50 | bar: number; 51 | baz: TestObjType; 52 | }; 53 | }; 54 | const storage = createStorage(); 55 | 56 | expectTypeOf(await storage.getItem("foo")).toEqualTypeOf(); 57 | expectTypeOf(await storage.getItem("bar")).toEqualTypeOf(); 58 | expectTypeOf( 59 | await storage.getItem("unknown") 60 | ).toEqualTypeOf(); 61 | expectTypeOf(await storage.get("baz")).toEqualTypeOf(); 62 | 63 | // @ts-expect-error 64 | await storage.setItem("foo", 1); // ts err: Argument of type 'number' is not assignable to parameter of type 'string' 65 | await storage.setItem("foo", "str"); 66 | // @ts-expect-error 67 | await storage.set("bar", "str"); // ts err: Argument of type 'string' is not assignable to parameter of type 'number'. 68 | await storage.set("bar", 1); 69 | 70 | // should be able to get ts prompts: 'foo' | 'bar' | 'baz' 71 | await storage.removeItem("foo"); 72 | await storage.remove("bar"); 73 | await storage.del("baz"); 74 | }); 75 | 76 | it("prefix storage", () => { 77 | const storage1 = createStorage(); 78 | const prefixedStorage1 = prefixStorage(storage1, "foo"); 79 | expectTypeOf(prefixedStorage1).toEqualTypeOf>(); 80 | 81 | const storage2 = createStorage(); 82 | const prefixedStorage2 = prefixStorage(storage2, "foo"); 83 | expectTypeOf(prefixedStorage2).toEqualTypeOf>(); 84 | 85 | const storage3 = createStorage(); 86 | const prefixedStorage3 = prefixStorage(storage3, "foo"); 87 | expectTypeOf(prefixedStorage3).toEqualTypeOf>(); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/drivers/upstash.ts: -------------------------------------------------------------------------------- 1 | import { type RedisConfigNodejs, Redis } from "@upstash/redis"; 2 | import { defineDriver, normalizeKey, joinKeys } from "./utils/index.ts"; 3 | 4 | export interface UpstashOptions extends Partial { 5 | /** 6 | * Optional prefix to use for all keys. Can be used for namespacing. 7 | */ 8 | base?: string; 9 | 10 | /** 11 | * Default TTL for all items in seconds. 12 | */ 13 | ttl?: number; 14 | 15 | /** 16 | * How many keys to scan at once. 17 | * 18 | * [redis documentation](https://redis.io/docs/latest/commands/scan/#the-count-option) 19 | */ 20 | scanCount?: number; 21 | } 22 | 23 | const DRIVER_NAME = "upstash"; 24 | 25 | export default defineDriver( 26 | (options: UpstashOptions = {}) => { 27 | const base = normalizeKey(options?.base); 28 | const r = (...keys: string[]) => joinKeys(base, ...keys); 29 | 30 | let redisClient: Redis; 31 | const getClient = () => { 32 | if (redisClient) { 33 | return redisClient; 34 | } 35 | const url = 36 | options.url || globalThis.process?.env?.UPSTASH_REDIS_REST_URL; 37 | const token = 38 | options.token || globalThis.process?.env?.UPSTASH_REDIS_REST_TOKEN; 39 | redisClient = new Redis({ url, token, ...options }); 40 | return redisClient; 41 | }; 42 | 43 | const scan = async (pattern: string): Promise => { 44 | const client = getClient(); 45 | const keys: string[] = []; 46 | let cursor = "0"; 47 | do { 48 | const [nextCursor, scanKeys] = await client.scan(cursor, { 49 | match: pattern, 50 | count: options.scanCount, 51 | }); 52 | cursor = nextCursor; 53 | keys.push(...scanKeys); 54 | } while (cursor !== "0"); 55 | return keys; 56 | }; 57 | 58 | return { 59 | name: DRIVER_NAME, 60 | getInstance: getClient, 61 | async hasItem(key) { 62 | return Boolean(await getClient().exists(r(key))); 63 | }, 64 | async getItem(key) { 65 | return await getClient().get(r(key)); 66 | }, 67 | async getItems(items) { 68 | const keys = items.map((item) => r(item.key)); 69 | const data = await getClient().mget(...keys); 70 | 71 | return keys.map((key, index) => { 72 | return { 73 | key: base ? key.slice(base.length + 1) : key, 74 | value: data[index] ?? null, 75 | }; 76 | }); 77 | }, 78 | async setItem(key, value, tOptions) { 79 | const ttl = tOptions?.ttl || options.ttl; 80 | return getClient() 81 | .set(r(key), value, ttl ? { ex: ttl } : undefined) 82 | .then(() => {}); 83 | }, 84 | async removeItem(key) { 85 | await getClient().unlink(r(key)); 86 | }, 87 | async getKeys(_base) { 88 | return await scan(r(_base, "*")).then((keys) => 89 | base ? keys.map((key) => key.slice(base.length + 1)) : keys 90 | ); 91 | }, 92 | async clear(base) { 93 | const keys = await scan(r(base, "*")); 94 | if (keys.length === 0) { 95 | return; 96 | } 97 | await getClient().del(...keys); 98 | }, 99 | }; 100 | } 101 | ); 102 | -------------------------------------------------------------------------------- /src/drivers/uploadthing.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver, normalizeKey } from "./utils/index.ts"; 2 | import { UTApi } from "uploadthing/server"; 3 | 4 | // Reference: https://docs.uploadthing.com 5 | 6 | type UTApiOptions = Omit< 7 | Exclude[0], undefined>, 8 | "defaultKeyType" 9 | >; 10 | 11 | type FileEsque = Parameters[0][0]; 12 | 13 | export interface UploadThingOptions extends UTApiOptions { 14 | /** base key to add to keys */ 15 | base?: string; 16 | } 17 | 18 | const DRIVER_NAME = "uploadthing"; 19 | 20 | export default defineDriver((opts = {}) => { 21 | let client: UTApi; 22 | 23 | const base = opts.base ? normalizeKey(opts.base) : ""; 24 | const r = (key: string) => (base ? `${base}:${key}` : key); 25 | 26 | const getClient = () => { 27 | return (client ??= new UTApi({ 28 | ...opts, 29 | defaultKeyType: "customId", 30 | })); 31 | }; 32 | 33 | const getKeys = async (base: string) => { 34 | const client = getClient(); 35 | const { files } = await client.listFiles({}); 36 | return files 37 | .map((file) => file.customId) 38 | .filter((k) => k && k.startsWith(base)) as string[]; 39 | }; 40 | 41 | const toFile = (key: string, value: BlobPart) => { 42 | return Object.assign(new Blob([value]), { 43 | name: key, 44 | customId: key, 45 | }) satisfies FileEsque; 46 | }; 47 | 48 | return { 49 | name: DRIVER_NAME, 50 | getInstance() { 51 | return getClient(); 52 | }, 53 | getKeys(base) { 54 | return getKeys(r(base)); 55 | }, 56 | async hasItem(key) { 57 | const client = getClient(); 58 | const res = await client.getFileUrls(r(key)); 59 | return res.data.length > 0; 60 | }, 61 | async getItem(key) { 62 | const client = getClient(); 63 | const url = await client 64 | .getFileUrls(r(key)) 65 | .then((res) => res.data[0]?.url); 66 | if (!url) return null; 67 | return fetch(url).then((res) => res.text()); 68 | }, 69 | async getItemRaw(key) { 70 | const client = getClient(); 71 | const url = await client 72 | .getFileUrls(r(key)) 73 | .then((res) => res.data[0]?.url); 74 | if (!url) return null; 75 | return fetch(url).then((res) => res.arrayBuffer()); 76 | }, 77 | async setItem(key, value) { 78 | const client = getClient(); 79 | await client.uploadFiles(toFile(r(key), value)); 80 | }, 81 | async setItemRaw(key, value) { 82 | const client = getClient(); 83 | await client.uploadFiles(toFile(r(key), value)); 84 | }, 85 | async setItems(items) { 86 | const client = getClient(); 87 | await client.uploadFiles( 88 | items.map((item) => toFile(r(item.key), item.value)) 89 | ); 90 | }, 91 | async removeItem(key) { 92 | const client = getClient(); 93 | await client.deleteFiles([r(key)]); 94 | }, 95 | async clear(base) { 96 | const client = getClient(); 97 | const keys = await getKeys(r(base)); 98 | await client.deleteFiles(keys); 99 | }, 100 | // getMeta(key, opts) { 101 | // // TODO: We don't currently have an endpoint to fetch metadata, but it does exist 102 | // }, 103 | }; 104 | }); 105 | -------------------------------------------------------------------------------- /docs/2.drivers/0.index.md: -------------------------------------------------------------------------------- 1 | ---- 2 | icon: icon-park-outline:hard-disk 3 | --- 4 | 5 | # Drivers 6 | 7 | > Unstorage has several built-in drivers. 8 | 9 | ::card-group 10 | ::card 11 | --- 12 | icon: mdi:microsoft-azure 13 | to: /drivers/azure 14 | title: Azure 15 | color: gray 16 | --- 17 | Store data in Azure available storages. 18 | :: 19 | ::card 20 | --- 21 | icon: ph:browser-thin 22 | to: /drivers/browser 23 | title: Browser 24 | color: gray 25 | --- 26 | Store data in browser storages (localStorage, sessionStorage, indexedDB). 27 | :: 28 | ::card 29 | --- 30 | icon: nonicons:capacitor-16 31 | to: /drivers/capacitor-preferences 32 | title: Capacitor Preferences 33 | color: gray 34 | --- 35 | Store data via Capacitor Preferences API on mobile devices or local storage on the web. 36 | :: 37 | ::card 38 | --- 39 | icon: devicon-plain:cloudflareworkers 40 | to: /drivers/cloudflare 41 | title: Cloudflare 42 | color: gray 43 | --- 44 | Store data in Cloudflare KV or R2 storage. 45 | :: 46 | ::card 47 | --- 48 | icon: ph:file-light 49 | to: /drivers/fs 50 | title: Filesystem (Node.js) 51 | color: gray 52 | --- 53 | Store data in the filesystem using Node.js API. 54 | :: 55 | ::card 56 | --- 57 | icon: mdi:github 58 | to: /drivers/github 59 | title: GitHub 60 | color: gray 61 | --- 62 | Map files from a remote github repository (readonly). 63 | :: 64 | ::card 65 | --- 66 | icon: ic:baseline-http 67 | to: /drivers/http 68 | title: HTTP 69 | color: gray 70 | --- 71 | Use a remote HTTP/HTTPS endpoint as data storage. 72 | :: 73 | ::card 74 | --- 75 | icon: material-symbols:cached-rounded 76 | to: /drivers/lru-cache 77 | title: LRU Cache 78 | color: gray 79 | --- 80 | Keeps cached data in memory using LRU Cache. 81 | :: 82 | ::card 83 | --- 84 | icon: bi:memory 85 | to: /drivers/memory 86 | title: Memory 87 | color: gray 88 | --- 89 | Keep data in memory. 90 | :: 91 | ::card 92 | --- 93 | icon: teenyicons:mongodb-outline 94 | to: /drivers/mongodb 95 | title: MongoDB 96 | color: gray 97 | --- 98 | Store data in MongoDB database. 99 | :: 100 | ::card 101 | --- 102 | icon: teenyicons:netlify-solid 103 | to: /drivers/netlify 104 | title: Netlify Blobs 105 | color: gray 106 | --- 107 | Store data in Netlify Blobs. 108 | :: 109 | ::card 110 | --- 111 | icon: carbon:overlay 112 | to: /drivers/overlay 113 | title: Overlay 114 | color: gray 115 | --- 116 | Create a multi-layer overlay driver. 117 | :: 118 | ::card 119 | --- 120 | icon: simple-icons:planetscale 121 | to: /drivers/planetscale 122 | title: PlanetScale 123 | color: gray 124 | --- 125 | Store data in PlanetScale database. 126 | :: 127 | ::card 128 | --- 129 | icon: simple-icons:redis 130 | to: /drivers/redis 131 | title: Redis 132 | color: gray 133 | --- 134 | Store data in Redis. 135 | :: 136 | ::card 137 | --- 138 | icon: ph:database 139 | to: /drivers/database 140 | title: SQL Database 141 | color: gray 142 | --- 143 | Store data in SQL database. 144 | :: 145 | ::card 146 | --- 147 | icon: gg:vercel 148 | to: /drivers/vercel 149 | title: Vercel KV 150 | color: gray 151 | --- 152 | Store data in Vercel KV. 153 | :: 154 | :: 155 | -------------------------------------------------------------------------------- /src/drivers/planetscale.ts: -------------------------------------------------------------------------------- 1 | import { createRequiredError, defineDriver } from "./utils/index.ts"; 2 | import type { ExecutedQuery, Connection } from "@planetscale/database"; 3 | import { connect } from "@planetscale/database"; 4 | 5 | export interface PlanetscaleDriverOptions { 6 | url?: string; 7 | table?: string; 8 | boostCache?: boolean; 9 | } 10 | 11 | interface TableSchema { 12 | id: string; 13 | value: string; 14 | created_at: Date; 15 | updated_at: Date; 16 | } 17 | 18 | const DRIVER_NAME = "planetscale"; 19 | 20 | export default defineDriver((opts: PlanetscaleDriverOptions = {}) => { 21 | opts.table = opts.table || "storage"; 22 | 23 | let _connection: Connection; 24 | const getConnection = () => { 25 | if (!_connection) { 26 | if (!opts.url) { 27 | throw createRequiredError(DRIVER_NAME, "url"); 28 | } 29 | // `connect` configures a connection class rather than initiating a connection 30 | _connection = connect({ 31 | url: opts.url, 32 | fetch, 33 | }); 34 | if (opts.boostCache) { 35 | // This query will be executed in background 36 | _connection 37 | .execute("SET @@boost_cached_queries = true;") 38 | .catch((error) => { 39 | console.error( 40 | "[unstorage] [planetscale] Failed to enable cached queries:", 41 | error 42 | ); 43 | }); 44 | } 45 | } 46 | return _connection; 47 | }; 48 | 49 | return { 50 | name: DRIVER_NAME, 51 | options: opts, 52 | getInstance: getConnection, 53 | hasItem: async (key) => { 54 | const res = await getConnection().execute( 55 | `SELECT EXISTS (SELECT 1 FROM ${opts.table} WHERE id = :key) as value;`, 56 | { key } 57 | ); 58 | return rows<{ value: string }[]>(res)[0]?.value == "1"; 59 | }, 60 | getItem: async (key) => { 61 | const res = await getConnection().execute( 62 | `SELECT value from ${opts.table} WHERE id=:key;`, 63 | { key } 64 | ); 65 | return rows(res)[0]?.value ?? null; 66 | }, 67 | setItem: async (key, value) => { 68 | await getConnection().execute( 69 | `INSERT INTO ${opts.table} (id, value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE value = :value;`, 70 | { key, value } 71 | ); 72 | }, 73 | removeItem: async (key) => { 74 | await getConnection().execute( 75 | `DELETE FROM ${opts.table} WHERE id=:key;`, 76 | { key } 77 | ); 78 | }, 79 | getMeta: async (key) => { 80 | const res = await getConnection().execute( 81 | `SELECT created_at, updated_at from ${opts.table} WHERE id=:key;`, 82 | { key } 83 | ); 84 | return { 85 | birthtime: rows(res)[0]?.created_at, 86 | mtime: rows(res)[0]?.updated_at, 87 | }; 88 | }, 89 | getKeys: async (base = "") => { 90 | const res = await getConnection().execute( 91 | `SELECT id from ${opts.table} WHERE id LIKE :base;`, 92 | { base: `${base}%` } 93 | ); 94 | return rows(res).map((r) => r.id); 95 | }, 96 | clear: async () => { 97 | await getConnection().execute(`DELETE FROM ${opts.table};`); 98 | }, 99 | }; 100 | }); 101 | 102 | function rows(res: ExecutedQuery) { 103 | return (res.rows as T) || []; 104 | } 105 | -------------------------------------------------------------------------------- /test/drivers/azure-storage-blob.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, beforeAll, afterAll, test } from "vitest"; 2 | import driver from "../../src/drivers/azure-storage-blob.ts"; 3 | import { testDriver } from "./utils.ts"; 4 | import { AccountSASPermissions, BlobServiceClient } from "@azure/storage-blob"; 5 | import { ChildProcess, exec } from "node:child_process"; 6 | import { createStorage } from "../../src/index.ts"; 7 | 8 | describe.skip("drivers: azure-storage-blob", () => { 9 | let azuriteProcess: ChildProcess; 10 | let sasUrl: string; 11 | beforeAll(async () => { 12 | azuriteProcess = exec("pnpm exec azurite-blob --silent"); 13 | const client = BlobServiceClient.fromConnectionString( 14 | "UseDevelopmentStorage=true;" 15 | ); 16 | const containerClient = client.getContainerClient("unstorage"); 17 | await containerClient.createIfNotExists(); 18 | sasUrl = client.generateAccountSasUrl( 19 | new Date(Date.now() + 1000 * 60), 20 | AccountSASPermissions.from({ read: true, list: true, write: true }) 21 | ); 22 | }); 23 | afterAll(() => { 24 | azuriteProcess.kill(9); 25 | }); 26 | testDriver({ 27 | driver: driver({ 28 | connectionString: "UseDevelopmentStorage=true;", 29 | accountName: "devstoreaccount1", 30 | }), 31 | additionalTests() { 32 | test("no empty account name", async () => { 33 | const invalidStorage = createStorage({ 34 | driver: driver({ 35 | accountKey: "UseDevelopmentStorage=true", 36 | } as any), 37 | }); 38 | await expect( 39 | async () => await invalidStorage.hasItem("test") 40 | ).rejects.toThrowError("missing accountName"); 41 | }); 42 | test("sas key", async ({ skip }) => { 43 | if ( 44 | !process.env.AZURE_STORAGE_BLOB_SAS_KEY || 45 | !process.env.AZURE_STORAGE_BLOB_ACCOUNT_NAME 46 | ) { 47 | skip(); 48 | } 49 | const storage = createStorage({ 50 | driver: driver({ 51 | sasKey: process.env.AZURE_STORAGE_BLOB_SAS_KEY, 52 | accountName: process.env.AZURE_STORAGE_BLOB_ACCOUNT_NAME, 53 | containerName: "unstorage", 54 | }), 55 | }); 56 | await storage.getKeys(); 57 | }); 58 | test("sas url", async () => { 59 | const storage = createStorage({ 60 | driver: driver({ 61 | sasUrl, 62 | containerName: "unstorage", 63 | }), 64 | }); 65 | await storage.getKeys(); 66 | }); 67 | test("account key", async ({ skip }) => { 68 | if ( 69 | !process.env.AZURE_STORAGE_BLOB_ACCOUNT_KEY || 70 | !process.env.AZURE_STORAGE_BLOB_ACCOUNT_NAME 71 | ) { 72 | skip(); 73 | } 74 | const storage = createStorage({ 75 | driver: driver({ 76 | accountName: process.env.AZURE_STORAGE_BLOB_ACCOUNT_NAME, 77 | accountKey: process.env.AZURE_STORAGE_BLOB_ACCOUNT_KEY, 78 | }), 79 | }); 80 | await storage.getKeys(); 81 | }); 82 | test("use DefaultAzureCredential", async ({ skip }) => { 83 | if (!process.env.AZURE_STORAGE_BLOB_ACCOUNT_NAME) { 84 | skip(); 85 | } 86 | const storage = createStorage({ 87 | driver: driver({ 88 | accountName: process.env.AZURE_STORAGE_BLOB_ACCOUNT_NAME, 89 | containerName: "unstorage", 90 | }), 91 | }); 92 | await storage.getKeys(); 93 | }); 94 | }, 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/drivers/vercel-runtime-cache.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver, normalizeKey, joinKeys } from "./utils/index.ts"; 2 | import type { RuntimeCache } from "@vercel/functions"; 3 | 4 | export interface VercelCacheOptions { 5 | /** 6 | * Optional prefix to use for all keys. Can be used for namespacing. 7 | */ 8 | base?: string; 9 | 10 | /** 11 | * Default TTL for all items in seconds. 12 | */ 13 | ttl?: number; 14 | 15 | /** 16 | * Default tags to apply to all cache entries. 17 | */ 18 | tags?: string[]; 19 | } 20 | 21 | const DRIVER_NAME = "vercel-runtime-cache"; 22 | 23 | export default defineDriver((opts) => { 24 | const base = normalizeKey(opts?.base); 25 | const r = (...keys: string[]) => joinKeys(base, ...keys); 26 | 27 | let _cache: RuntimeCache; 28 | 29 | const getClient = () => { 30 | if (!_cache) { 31 | _cache = getCache(); 32 | } 33 | return _cache; 34 | }; 35 | 36 | return { 37 | name: DRIVER_NAME, 38 | getInstance: getClient, 39 | async hasItem(key) { 40 | const value = await getClient().get(r(key)); 41 | return value !== undefined && value !== null; 42 | }, 43 | async getItem(key) { 44 | const value = await getClient().get(r(key)); 45 | return value === undefined ? null : value; 46 | }, 47 | async setItem(key, value, tOptions) { 48 | const ttl = tOptions?.ttl ?? opts?.ttl; 49 | const tags = [...(tOptions?.tags || []), ...(opts?.tags || [])].filter( 50 | Boolean 51 | ); 52 | 53 | await getClient().set(r(key), value, { 54 | ttl, 55 | tags, 56 | }); 57 | }, 58 | async removeItem(key) { 59 | await getClient().delete(r(key)); 60 | }, 61 | async getKeys(_base) { 62 | // Runtime Cache doesn't provide a way to list keys 63 | return []; 64 | }, 65 | async clear(_base) { 66 | // Runtime Cache doesn't provide a way to clear all keys 67 | // You can only expire by tags 68 | if (opts?.tags && opts.tags.length > 0) { 69 | await getClient().expireTag(opts.tags); 70 | } 71 | }, 72 | }; 73 | }); 74 | 75 | // --- internal --- 76 | 77 | // Derived from Apache 2.0 licensed code: 78 | // https://github.com/vercel/vercel/blob/main/packages/functions/src/cache 79 | // Copyright 2017 Vercel, Inc. 80 | 81 | type Context = { cache?: RuntimeCache }; 82 | 83 | const SYMBOL_FOR_REQ_CONTEXT = /*#__PURE__*/ Symbol.for( 84 | "@vercel/request-context" 85 | ); 86 | 87 | function getContext(): Context { 88 | const fromSymbol: typeof globalThis & { 89 | [SYMBOL_FOR_REQ_CONTEXT]?: { get?: () => Context }; 90 | } = globalThis; 91 | return fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.() ?? {}; 92 | } 93 | 94 | function getCache(): RuntimeCache { 95 | const cache = 96 | getContext()?.cache || 97 | tryRequireVCFunctions()?.getCache?.({ 98 | keyHashFunction: (key) => key, 99 | namespaceSeparator: ":", 100 | }); 101 | if (!cache) { 102 | throw new Error("Runtime cache is not available!"); 103 | } 104 | return cache; 105 | } 106 | 107 | let _vcFunctionsLib: typeof import("@vercel/functions") | undefined; 108 | 109 | function tryRequireVCFunctions() { 110 | if (!_vcFunctionsLib) { 111 | const { createRequire } = 112 | globalThis.process?.getBuiltinModule?.("node:module") || {}; 113 | _vcFunctionsLib = createRequire?.(import.meta.url)("@vercel/functions"); 114 | } 115 | return _vcFunctionsLib; 116 | } 117 | -------------------------------------------------------------------------------- /src/drivers/cloudflare-r2-binding.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineDriver, joinKeys } from "./utils/index.ts"; 3 | import { getR2Binding } from "./utils/cloudflare.ts"; 4 | 5 | export interface CloudflareR2Options { 6 | binding?: string | R2Bucket; 7 | base?: string; 8 | } 9 | 10 | // https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ 11 | 12 | const DRIVER_NAME = "cloudflare-r2-binding"; 13 | 14 | export default defineDriver((opts: CloudflareR2Options = {}) => { 15 | const r = (key: string = "") => (opts.base ? joinKeys(opts.base, key) : key); 16 | 17 | const getKeys = async (base?: string) => { 18 | const binding = getR2Binding(opts.binding); 19 | const kvList = await binding.list( 20 | base || opts.base ? { prefix: r(base) } : undefined 21 | ); 22 | return kvList.objects.map((obj) => obj.key); 23 | }; 24 | 25 | return { 26 | name: DRIVER_NAME, 27 | options: opts, 28 | getInstance: () => getR2Binding(opts.binding), 29 | async hasItem(key) { 30 | key = r(key); 31 | const binding = getR2Binding(opts.binding); 32 | return (await binding.head(key)) !== null; 33 | }, 34 | async getMeta(key) { 35 | key = r(key); 36 | const binding = getR2Binding(opts.binding); 37 | const obj = await binding.head(key); 38 | if (!obj) return null; 39 | return { 40 | mtime: obj.uploaded, 41 | atime: obj.uploaded, 42 | ...obj, 43 | }; 44 | }, 45 | getItem(key, topts) { 46 | key = r(key); 47 | const binding = getR2Binding(opts.binding); 48 | return binding.get(key, topts).then((r) => r?.text() ?? null); 49 | }, 50 | async getItemRaw(key, topts) { 51 | key = r(key); 52 | const binding = getR2Binding(opts.binding); 53 | const object = await binding.get(key, topts); 54 | return object ? getObjBody(object, topts?.type) : null; 55 | }, 56 | async setItem(key, value, topts) { 57 | key = r(key); 58 | const binding = getR2Binding(opts.binding); 59 | await binding.put(key, value, topts); 60 | }, 61 | async setItemRaw(key, value, topts) { 62 | key = r(key); 63 | const binding = getR2Binding(opts.binding); 64 | await binding.put(key, value, topts); 65 | }, 66 | async removeItem(key) { 67 | key = r(key); 68 | const binding = getR2Binding(opts.binding); 69 | await binding.delete(key); 70 | }, 71 | getKeys(base) { 72 | return getKeys(base).then((keys) => 73 | opts.base ? keys.map((key) => key.slice(opts.base!.length)) : keys 74 | ); 75 | }, 76 | async clear(base) { 77 | const binding = getR2Binding(opts.binding); 78 | const keys = await getKeys(base); 79 | await binding.delete(keys); 80 | }, 81 | }; 82 | }); 83 | 84 | function getObjBody( 85 | object: R2ObjectBody, 86 | type: "object" | "stream" | "blob" | "arrayBuffer" | "bytes" 87 | ) { 88 | switch (type) { 89 | case "object": { 90 | return object; 91 | } 92 | case "stream": { 93 | return object.body; 94 | } 95 | case "blob": { 96 | return object.blob(); 97 | } 98 | case "arrayBuffer": { 99 | return object.arrayBuffer(); 100 | } 101 | case "bytes": { 102 | return object.arrayBuffer().then((buffer) => new Uint8Array(buffer)); 103 | } 104 | // TODO: Default to bytes in v2 105 | default: { 106 | return object.arrayBuffer(); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Storage, StorageValue, TransactionOptions } from "./types.ts"; 2 | 3 | type StorageKeys = Array; 4 | 5 | const storageKeyProperties: StorageKeys = [ 6 | "has", 7 | "hasItem", 8 | "get", 9 | "getItem", 10 | "getItemRaw", 11 | "set", 12 | "setItem", 13 | "setItemRaw", 14 | "del", 15 | "remove", 16 | "removeItem", 17 | "getMeta", 18 | "setMeta", 19 | "removeMeta", 20 | "getKeys", 21 | "clear", 22 | "mount", 23 | "unmount", 24 | ]; 25 | 26 | export function prefixStorage( 27 | storage: Storage | Storage, 28 | base: string 29 | ): Storage; 30 | export function prefixStorage( 31 | storage: Storage, 32 | base: string 33 | ): Storage { 34 | base = normalizeBaseKey(base); 35 | if (!base) { 36 | return storage; 37 | } 38 | const nsStorage: Storage = { ...storage }; 39 | for (const property of storageKeyProperties) { 40 | // @ts-ignore 41 | nsStorage[property] = (key = "", ...args) => 42 | // @ts-ignore 43 | storage[property](base + key, ...args); 44 | } 45 | nsStorage.getKeys = (key = "", ...arguments_) => 46 | storage 47 | .getKeys(base + key, ...arguments_) 48 | // Remove Prefix 49 | .then((keys) => keys.map((key) => key.slice(base.length))); 50 | 51 | nsStorage.keys = nsStorage.getKeys; 52 | 53 | nsStorage.getItems = async ( 54 | items: (string | { key: string; options?: TransactionOptions })[], 55 | commonOptions?: TransactionOptions 56 | ) => { 57 | const prefixedItems = items.map((item) => 58 | typeof item === "string" ? base + item : { ...item, key: base + item.key } 59 | ); 60 | const results = await storage.getItems(prefixedItems, commonOptions); 61 | return results.map((entry) => ({ 62 | key: entry.key.slice(base.length), 63 | value: entry.value, 64 | })); 65 | }; 66 | 67 | nsStorage.setItems = async ( 68 | items: { key: string; value: U; options?: TransactionOptions }[], 69 | commonOptions?: TransactionOptions 70 | ) => { 71 | const prefixedItems = items.map((item) => ({ 72 | key: base + item.key, 73 | value: item.value, 74 | options: item.options, 75 | })); 76 | return storage.setItems(prefixedItems, commonOptions); 77 | }; 78 | 79 | return nsStorage; 80 | } 81 | 82 | export function normalizeKey(key?: string) { 83 | if (!key) { 84 | return ""; 85 | } 86 | return ( 87 | key 88 | .split("?")[0] 89 | ?.replace(/[/\\]/g, ":") 90 | .replace(/:+/g, ":") 91 | .replace(/^:|:$/g, "") || "" 92 | ); 93 | } 94 | 95 | export function joinKeys(...keys: string[]) { 96 | return normalizeKey(keys.join(":")); 97 | } 98 | 99 | export function normalizeBaseKey(base?: string) { 100 | base = normalizeKey(base); 101 | return base ? base + ":" : ""; 102 | } 103 | 104 | export function filterKeyByDepth( 105 | key: string, 106 | depth: number | undefined 107 | ): boolean { 108 | if (depth === undefined) { 109 | return true; 110 | } 111 | 112 | let substrCount = 0; 113 | let index = key.indexOf(":"); 114 | 115 | while (index > -1) { 116 | substrCount++; 117 | index = key.indexOf(":", index + 1); 118 | } 119 | 120 | return substrCount <= depth; 121 | } 122 | 123 | export function filterKeyByBase( 124 | key: string, 125 | base: string | undefined 126 | ): boolean { 127 | if (base) { 128 | return key.startsWith(base) && key[key.length - 1] !== "$"; 129 | } 130 | 131 | return key[key.length - 1] !== "$"; 132 | } 133 | -------------------------------------------------------------------------------- /docs/2.drivers/netlify.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: teenyicons:netlify-solid 3 | --- 4 | 5 | # Netlify Blobs 6 | 7 | > Store data in Netlify Blobs. 8 | 9 | Store data in a [Netlify Blobs](https://docs.netlify.com/blobs/overview/) store. This is supported in both [edge](#using-in-netlify-edge) and Node.js function runtimes, as well as during builds. 10 | 11 | ::read-more{title="Netlify Blobs" to="https://docs.netlify.com/blobs/overview/"} 12 | :: 13 | 14 | ## Usage 15 | 16 | **Driver name:** `netlify-blobs` 17 | 18 | ```js 19 | import { createStorage } from "unstorage"; 20 | import netlifyBlobsDriver from "unstorage/drivers/netlify-blobs"; 21 | 22 | const storage = createStorage({ 23 | driver: netlifyBlobsDriver({ 24 | name: "blob-store-name", 25 | }), 26 | }); 27 | ``` 28 | 29 | You can create a deploy-scoped store by setting `deployScoped` option to `true`. This will mean that the deploy only has access to its own store. The store is managed alongside the deploy, with the same deploy previews, deletes, and rollbacks. This is required during builds, which only have access to deploy-scoped stores. 30 | 31 | ```js 32 | import { createStorage } from "unstorage"; 33 | import netlifyBlobsDriver from "unstorage/drivers/netlify-blobs"; 34 | 35 | const storage = createStorage({ 36 | driver: netlifyBlobsDriver({ 37 | deployScoped: true, 38 | }), 39 | }); 40 | ``` 41 | 42 | To use, you will need to install `@netlify/blobs` as dependency or devDependency in your project: 43 | 44 | ```json 45 | { 46 | "devDependencies": { 47 | "@netlify/blobs": "latest" 48 | } 49 | } 50 | ``` 51 | 52 | **Options:** 53 | 54 | - `name` - The name of the store to use. It is created if needed. This is required except for deploy-scoped stores. 55 | - `deployScoped` - If set to `true`, the store is scoped to the deploy. This means that it is only available from that deploy, and will be deleted or rolled-back alongside it. 56 | - `consistency` - The [consistency model](https://docs.netlify.com/blobs/overview/#consistency) to use for the store. This can be `eventual` or `strong`. Default is `eventual`. 57 | - `siteID` - Required during builds, where it is available as `constants.SITE_ID`. At runtime this is set automatically. 58 | - `token` - Required during builds, where it is available as `constants.NETLIFY_API_TOKEN`. At runtime this is set automatically. 59 | 60 | **Advanced options:** 61 | 62 | These are not normally needed, but are available for advanced use cases or for use in unit tests. 63 | 64 | - `apiURL` 65 | - `edgeURL` 66 | - `uncachedEdgeURL` 67 | 68 | ## Using in Netlify edge functions 69 | 70 | When using Unstorage in a Netlify edge function you should use a URL import. This does not apply if you are compiling your code in a framework - just if you are creating your own edge functions. 71 | 72 | ```js 73 | import { createStorage } from "https://esm.sh/unstorage"; 74 | import netlifyBlobsDriver from "https://esm.sh/unstorage/drivers/netlify-blobs"; 75 | 76 | export default async function handler(request: Request) { 77 | 78 | const storage = createStorage({ 79 | driver: netlifyBlobsDriver({ 80 | name: "blob-store-name", 81 | }), 82 | }); 83 | 84 | // ... 85 | } 86 | ``` 87 | 88 | ## Updating stores from Netlify Blobs beta 89 | 90 | There has been a change in the way global blob stores are stored in `@netlify/blobs` version `7.0.0` which means that you will not be able to access objects in global stores created by older versions until you migrate them. This does not affect deploy-scoped stores, nor does it affect objects created with the new version. You can migrate objects in your old stores by running the following command in the project directory using the latest version of the Netlify CLI: 91 | 92 | ```sh 93 | netlify recipes blobs-migrate 94 | ``` 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💾 Unstorage 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![Codecov][codecov-src]][codecov-href] 6 | [![bundle][bundle-src]][bundle-href] 7 | [![License][license-src]][license-href] 8 | 9 | 10 | 11 | Unstorage provides an async Key-Value storage API with conventional features like multi driver mounting, watching and working with metadata, dozens of built-in drivers and a [tiny core](https://bundlephobia.com/package/unstorage). 12 | 13 | 👉 [Documentation](https://unstorage.unjs.io) 14 | 15 | ## Features 16 | 17 | - Designed for all environments: Browser, NodeJS, and Workers 18 | - Lots of Built-in drivers 19 | - Asynchronous API 20 | - Unix-style driver mounting to combine storages 21 | - Default [in-memory](https://unstorage.unjs.io/drivers/memory) storage 22 | - Tree-shakable utils and tiny core 23 | - Auto JSON value serialization and deserialization 24 | - Binary and raw value support 25 | - State [snapshots](https://unstorage.unjs.io/getting-started/utils#snapshots) and hydration 26 | - Storage watcher 27 | - HTTP Storage with [built-in server](https://unstorage.unjs.io/guide/http-server) 28 | 29 | ## Usage 30 | 31 | Install `unstorage` npm package: 32 | 33 | ```sh 34 | # yarn 35 | yarn add unstorage 36 | 37 | # npm 38 | npm install unstorage 39 | 40 | # pnpm 41 | pnpm add unstorage 42 | ``` 43 | 44 | ```js 45 | import { createStorage } from "unstorage"; 46 | 47 | const storage = createStorage(/* opts */); 48 | 49 | await storage.getItem("foo:bar"); // or storage.getItem('/foo/bar') 50 | ``` 51 | 52 | 👉 Check out the [the documentation](https://unstorage.unjs.io) for usage information. 53 | 54 | ## Nightly release channel 55 | 56 | You can use the nightly release channel to try the latest changes in the `main` branch via [`unstorage-nightly`](https://www.npmjs.com/package/unstorage-nightly). 57 | 58 | If directly using `unstorage` in your project: 59 | 60 | ```json 61 | { 62 | "devDependencies": { 63 | "unstorage": "npm:unstorage-nightly" 64 | } 65 | } 66 | ``` 67 | 68 | If using `unstorage` via another tool in your project: 69 | 70 | ```json 71 | { 72 | "resolutions": { 73 | "unstorage": "npm:unstorage-nightly" 74 | } 75 | } 76 | ``` 77 | 78 | ## Contribution 79 | 80 | - Clone repository 81 | - Install dependencies with `pnpm install` 82 | - Use `pnpm dev` to start jest watcher verifying changes 83 | - Use `pnpm test` before pushing to ensure all tests and lint checks passing 84 | 85 | ## License 86 | 87 | [MIT](./LICENSE) 88 | 89 | 90 | 91 | [npm-version-src]: https://img.shields.io/npm/v/unstorage?style=flat&colorA=18181B&colorB=F0DB4F 92 | [npm-version-href]: https://npmjs.com/package/unstorage 93 | [npm-downloads-src]: https://img.shields.io/npm/dm/unstorage?style=flat&colorA=18181B&colorB=F0DB4F 94 | [npm-downloads-href]: https://npmjs.com/package/unstorage 95 | [github-actions-src]: https://img.shields.io/github/workflow/status/unjs/unstorage/ci/main?style=flat&colorA=18181B&colorB=F0DB4F 96 | [github-actions-href]: https://github.com/unjs/unstorage/actions?query=workflow%3Aci 97 | [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/unstorage/main?style=flat&colorA=18181B&colorB=F0DB4F 98 | [codecov-href]: https://codecov.io/gh/unjs/unstorage 99 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/unstorage?style=flat&colorA=18181B&colorB=F0DB4F 100 | [bundle-href]: https://bundlephobia.com/result?p=unstorage 101 | [license-src]: https://img.shields.io/github/license/unjs/unstorage.svg?style=flat&colorA=18181B&colorB=F0DB4F 102 | [license-href]: https://github.com/unjs/unstorage/blob/main/LICENSE 103 | -------------------------------------------------------------------------------- /src/drivers/http.ts: -------------------------------------------------------------------------------- 1 | import type { TransactionOptions } from "../types.ts"; 2 | import { defineDriver } from "./utils/index.ts"; 3 | import { type FetchError, $fetch as _fetch } from "ofetch"; 4 | import { joinURL } from "./utils/path.ts"; 5 | 6 | export interface HTTPOptions { 7 | base: string; 8 | headers?: Record; 9 | } 10 | 11 | const DRIVER_NAME = "http"; 12 | 13 | export default defineDriver((opts: HTTPOptions) => { 14 | const r = (key: string = "") => joinURL(opts.base!, key.replace(/:/g, "/")); 15 | 16 | const rBase = (key: string = "") => 17 | joinURL(opts.base!, (key || "/").replace(/:/g, "/") + ":"); 18 | 19 | const catchFetchError = (error: FetchError, fallbackVal: any = null) => { 20 | if (error?.response?.status === 404) { 21 | return fallbackVal; 22 | } 23 | throw error; 24 | }; 25 | 26 | const getHeaders = ( 27 | topts: TransactionOptions | undefined, 28 | defaultHeaders?: Record 29 | ) => { 30 | const headers = { 31 | ...defaultHeaders, 32 | ...opts.headers, 33 | ...topts?.headers, 34 | }; 35 | if (topts?.ttl && !headers["x-ttl"]) { 36 | headers["x-ttl"] = topts.ttl + ""; 37 | } 38 | return headers; 39 | }; 40 | 41 | return { 42 | name: DRIVER_NAME, 43 | options: opts, 44 | hasItem(key, topts) { 45 | return _fetch(r(key), { 46 | method: "HEAD", 47 | headers: getHeaders(topts), 48 | }) 49 | .then(() => true) 50 | .catch((err) => catchFetchError(err, false)); 51 | }, 52 | async getItem(key, tops) { 53 | const value = await _fetch(r(key), { 54 | headers: getHeaders(tops), 55 | }).catch(catchFetchError); 56 | return value; 57 | }, 58 | async getItemRaw(key, topts) { 59 | const response = await _fetch 60 | .raw(r(key), { 61 | responseType: "arrayBuffer", 62 | headers: getHeaders(topts, { accept: "application/octet-stream" }), 63 | }) 64 | .catch(catchFetchError); 65 | return response._data; 66 | }, 67 | async getMeta(key, topts) { 68 | const res = await _fetch.raw(r(key), { 69 | method: "HEAD", 70 | headers: getHeaders(topts), 71 | }); 72 | let mtime: Date | undefined; 73 | let ttl: number | undefined; 74 | const _lastModified = res.headers.get("last-modified"); 75 | if (_lastModified) { 76 | mtime = new Date(_lastModified); 77 | } 78 | const _ttl = res.headers.get("x-ttl"); 79 | if (_ttl) { 80 | ttl = Number.parseInt(_ttl, 10); 81 | } 82 | return { 83 | status: res.status, 84 | mtime, 85 | ttl, 86 | }; 87 | }, 88 | async setItem(key, value, topts) { 89 | await _fetch(r(key), { 90 | method: "PUT", 91 | body: value, 92 | headers: getHeaders(topts), 93 | }); 94 | }, 95 | async setItemRaw(key, value, topts) { 96 | await _fetch(r(key), { 97 | method: "PUT", 98 | body: value, 99 | headers: getHeaders(topts, { 100 | "content-type": "application/octet-stream", 101 | }), 102 | }); 103 | }, 104 | async removeItem(key, topts) { 105 | await _fetch(r(key), { 106 | method: "DELETE", 107 | headers: getHeaders(topts), 108 | }); 109 | }, 110 | async getKeys(base, topts) { 111 | const value = await _fetch(rBase(base), { 112 | headers: getHeaders(topts), 113 | }); 114 | return Array.isArray(value) ? value : []; 115 | }, 116 | async clear(base, topts) { 117 | await _fetch(rBase(base), { 118 | method: "DELETE", 119 | headers: getHeaders(topts), 120 | }); 121 | }, 122 | }; 123 | }); 124 | -------------------------------------------------------------------------------- /src/drivers/mongodb.ts: -------------------------------------------------------------------------------- 1 | import { createRequiredError, defineDriver } from "./utils/index.ts"; 2 | import { MongoClient, type Collection, type MongoClientOptions } from "mongodb"; 3 | 4 | export interface MongoDbOptions { 5 | /** 6 | * The MongoDB connection string. 7 | */ 8 | connectionString: string; 9 | 10 | /** 11 | * Optional configuration settings for the MongoClient instance. 12 | */ 13 | clientOptions?: MongoClientOptions; 14 | 15 | /** 16 | * The name of the database to use. 17 | * @default "unstorage" 18 | */ 19 | databaseName?: string; 20 | 21 | /** 22 | * The name of the collection to use. 23 | * @default "unstorage" 24 | */ 25 | collectionName?: string; 26 | } 27 | 28 | const DRIVER_NAME = "mongodb"; 29 | 30 | export default defineDriver((opts: MongoDbOptions) => { 31 | let collection: Collection; 32 | const getMongoCollection = () => { 33 | if (!collection) { 34 | if (!opts.connectionString) { 35 | throw createRequiredError(DRIVER_NAME, "connectionString"); 36 | } 37 | const mongoClient = new MongoClient( 38 | opts.connectionString, 39 | opts.clientOptions 40 | ); 41 | const db = mongoClient.db(opts.databaseName || "unstorage"); 42 | collection = db.collection(opts.collectionName || "unstorage"); 43 | } 44 | return collection; 45 | }; 46 | 47 | return { 48 | name: DRIVER_NAME, 49 | options: opts, 50 | getInstance: getMongoCollection, 51 | async hasItem(key) { 52 | const result = await getMongoCollection().findOne({ key }); 53 | return !!result; 54 | }, 55 | async getItem(key) { 56 | const document = await getMongoCollection().findOne({ key }); 57 | return document?.value ?? null; 58 | }, 59 | async getItems(items) { 60 | const keys = items.map((item) => item.key); 61 | 62 | const result = await getMongoCollection() 63 | .find({ key: { $in: keys } }) 64 | .toArray(); 65 | 66 | // return result in correct order 67 | const resultMap = new Map(result.map((doc) => [doc.key, doc])); 68 | return keys.map((key) => { 69 | return { key: key, value: resultMap.get(key)?.value ?? null }; 70 | }); 71 | }, 72 | async setItem(key, value) { 73 | const currentDateTime = new Date(); 74 | await getMongoCollection().updateOne( 75 | { key }, 76 | { 77 | $set: { key, value, modifiedAt: currentDateTime }, 78 | $setOnInsert: { createdAt: currentDateTime }, 79 | }, 80 | { upsert: true } 81 | ); 82 | }, 83 | async setItems(items) { 84 | const currentDateTime = new Date(); 85 | const operations = items.map(({ key, value }) => ({ 86 | updateOne: { 87 | filter: { key }, 88 | update: { 89 | $set: { key, value, modifiedAt: currentDateTime }, 90 | $setOnInsert: { createdAt: currentDateTime }, 91 | }, 92 | upsert: true, 93 | }, 94 | })); 95 | await getMongoCollection().bulkWrite(operations); 96 | }, 97 | async removeItem(key) { 98 | await getMongoCollection().deleteOne({ key }); 99 | }, 100 | async getKeys() { 101 | return await getMongoCollection() 102 | .find() 103 | .project({ key: true }) 104 | .map((d) => d.key) 105 | .toArray(); 106 | }, 107 | async getMeta(key) { 108 | const document = await getMongoCollection().findOne({ key }); 109 | return document 110 | ? { 111 | mtime: document.modifiedAt, 112 | birthtime: document.createdAt, 113 | } 114 | : {}; 115 | }, 116 | async clear() { 117 | await getMongoCollection().deleteMany({}); 118 | }, 119 | }; 120 | }); 121 | -------------------------------------------------------------------------------- /src/drivers/vercel-kv.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@vercel/kv"; 2 | import type { VercelKV } from "@vercel/kv"; 3 | import type { RedisConfigNodejs } from "@upstash/redis"; 4 | 5 | import { 6 | defineDriver, 7 | normalizeKey, 8 | joinKeys, 9 | createError, 10 | } from "./utils/index.ts"; 11 | 12 | export interface VercelKVOptions extends Partial { 13 | /** 14 | * Optional prefix to use for all keys. Can be used for namespacing. 15 | */ 16 | base?: string; 17 | 18 | /** 19 | * Optional flag to customize environment variable prefix (Default is `KV`). Set to `false` to disable env inference for `url` and `token` options 20 | */ 21 | env?: false | string; 22 | 23 | /** 24 | * Default TTL for all items in seconds. 25 | */ 26 | ttl?: number; 27 | 28 | /** 29 | * How many keys to scan at once. 30 | * 31 | * [redis documentation](https://redis.io/docs/latest/commands/scan/#the-count-option) 32 | */ 33 | scanCount?: number; 34 | } 35 | 36 | const DRIVER_NAME = "vercel-kv"; 37 | 38 | export default defineDriver((opts) => { 39 | const base = normalizeKey(opts?.base); 40 | const r = (...keys: string[]) => joinKeys(base, ...keys); 41 | 42 | let _client: VercelKV; 43 | const getClient = () => { 44 | if (!_client) { 45 | const envPrefix = 46 | typeof process !== "undefined" && opts.env !== false 47 | ? `${opts.env || "KV"}_` 48 | : ""; 49 | if (!opts.url) { 50 | const envName = envPrefix + "REST_API_URL"; 51 | if (envPrefix && process.env[envName]) { 52 | opts.url = process.env[envName]; 53 | } else { 54 | throw createError( 55 | "vercel-kv", 56 | `missing required \`url\` option or '${envName}' env.` 57 | ); 58 | } 59 | } 60 | if (!opts.token) { 61 | const envName = envPrefix + "REST_API_TOKEN"; 62 | if (envPrefix && process.env[envName]) { 63 | opts.token = process.env[envName]; 64 | } else { 65 | throw createError( 66 | "vercel-kv", 67 | `missing required \`token\` option or '${envName}' env.` 68 | ); 69 | } 70 | } 71 | _client = createClient( 72 | opts as VercelKVOptions & { url: string; token: string } 73 | ); 74 | } 75 | return _client; 76 | }; 77 | 78 | const scan = async (pattern: string): Promise => { 79 | const client = getClient(); 80 | const keys: string[] = []; 81 | let cursor = "0"; 82 | do { 83 | const [nextCursor, scanKeys] = await client.scan(cursor, { 84 | match: pattern, 85 | count: opts.scanCount, 86 | }); 87 | cursor = nextCursor; 88 | keys.push(...scanKeys); 89 | } while (cursor !== "0"); 90 | return keys; 91 | }; 92 | 93 | return { 94 | name: DRIVER_NAME, 95 | getInstance: getClient, 96 | hasItem(key) { 97 | return getClient().exists(r(key)).then(Boolean); 98 | }, 99 | getItem(key) { 100 | return getClient().get(r(key)); 101 | }, 102 | setItem(key, value, tOptions) { 103 | const ttl = tOptions?.ttl ?? opts.ttl; 104 | return getClient() 105 | .set(r(key), value, ttl ? { ex: ttl } : undefined) 106 | .then(() => {}); 107 | }, 108 | removeItem(key) { 109 | return getClient() 110 | .unlink(r(key)) 111 | .then(() => {}); 112 | }, 113 | getKeys(base) { 114 | return scan(r(base, "*")); 115 | }, 116 | async clear(base) { 117 | const keys = await scan(r(base, "*")); 118 | if (keys.length === 0) { 119 | return; 120 | } 121 | return getClient() 122 | .del(...keys) 123 | .then(() => {}); 124 | }, 125 | }; 126 | }); 127 | -------------------------------------------------------------------------------- /src/drivers/deno-kv.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver, createError, normalizeKey } from "./utils/index.ts"; 2 | import type { Kv, KvKey } from "@deno/kv"; 3 | 4 | // https://docs.deno.com/deploy/kv/manual/ 5 | 6 | export interface DenoKvOptions { 7 | base?: string; 8 | path?: string; 9 | openKv?: () => Promise; 10 | /** 11 | * Default TTL for all items in seconds. 12 | */ 13 | ttl?: number; 14 | } 15 | interface DenoKVSetOptions { 16 | /** 17 | * TTL in seconds. 18 | */ 19 | ttl?: number; 20 | } 21 | 22 | const DRIVER_NAME = "deno-kv"; 23 | 24 | export default defineDriver>( 25 | (opts: DenoKvOptions = {}) => { 26 | const basePrefix: KvKey = opts.base 27 | ? normalizeKey(opts.base).split(":") 28 | : []; 29 | 30 | const r = (key: string = ""): KvKey => 31 | [...basePrefix, ...key.split(":")].filter(Boolean); 32 | 33 | let _kv: Promise | undefined; 34 | const getKv = () => { 35 | if (_kv) { 36 | return _kv; 37 | } 38 | if (opts.openKv) { 39 | _kv = opts.openKv(); 40 | } else { 41 | if (!globalThis.Deno) { 42 | throw createError( 43 | DRIVER_NAME, 44 | "Missing global `Deno`. Are you running in Deno? (hint: use `deno-kv-node` driver for Node.js)" 45 | ); 46 | } 47 | if (!Deno.openKv) { 48 | throw createError( 49 | DRIVER_NAME, 50 | "Missing `Deno.openKv`. Are you running Deno with --unstable-kv?" 51 | ); 52 | } 53 | _kv = Deno.openKv(opts.path); 54 | } 55 | return _kv; 56 | }; 57 | 58 | return { 59 | name: DRIVER_NAME, 60 | getInstance() { 61 | return getKv(); 62 | }, 63 | async hasItem(key) { 64 | const kv = await getKv(); 65 | const value = await kv.get(r(key)); 66 | return !!value.value; 67 | }, 68 | async getItem(key) { 69 | const kv = await getKv(); 70 | const value = await kv.get(r(key)); 71 | return value.value; 72 | }, 73 | async getItemRaw(key) { 74 | const kv = await getKv(); 75 | const value = await kv.get(r(key)); 76 | return value.value; 77 | }, 78 | async setItem(key, value, tOptions: DenoKVSetOptions) { 79 | const ttl = normalizeTTL(tOptions?.ttl ?? opts?.ttl); 80 | const kv = await getKv(); 81 | await kv.set(r(key), value, { expireIn: ttl }); 82 | }, 83 | async setItemRaw(key, value, tOptions: DenoKVSetOptions) { 84 | const ttl = normalizeTTL(tOptions?.ttl ?? opts?.ttl); 85 | const kv = await getKv(); 86 | await kv.set(r(key), value, { expireIn: ttl }); 87 | }, 88 | async removeItem(key) { 89 | const kv = await getKv(); 90 | await kv.delete(r(key)); 91 | }, 92 | async getKeys(base) { 93 | const kv = await getKv(); 94 | const keys: string[] = []; 95 | for await (const entry of kv.list({ prefix: r(base) })) { 96 | keys.push( 97 | (basePrefix.length > 0 98 | ? entry.key.slice(basePrefix.length) 99 | : entry.key 100 | ).join(":") 101 | ); 102 | } 103 | return keys; 104 | }, 105 | async clear(base) { 106 | const kv = await getKv(); 107 | const batch = kv.atomic(); 108 | for await (const entry of kv.list({ prefix: r(base) })) { 109 | batch.delete(entry.key as KvKey); 110 | } 111 | await batch.commit(); 112 | }, 113 | async dispose() { 114 | if (_kv) { 115 | const kv = await _kv; 116 | await kv.close(); 117 | _kv = undefined; 118 | } 119 | }, 120 | }; 121 | } 122 | ); 123 | 124 | // --- internal --- 125 | 126 | /** 127 | * Converts TTL from seconds to milliseconds. 128 | * @see https://docs.deno.com/deploy/kv/manual/key_expiration/ 129 | */ 130 | function normalizeTTL(ttl: number | undefined): number | undefined { 131 | return typeof ttl === "number" && ttl > 0 ? ttl * 1000 : undefined; 132 | } 133 | -------------------------------------------------------------------------------- /src/drivers/netlify-blobs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createError, 3 | createRequiredError, 4 | defineDriver, 5 | } from "./utils/index.ts"; 6 | import type { GetKeysOptions } from "../types.ts"; 7 | import { getStore, getDeployStore } from "@netlify/blobs"; 8 | import type { 9 | Store, 10 | BlobResponseType, 11 | // NOTE: this type is different in v10+ vs. pre-v10 12 | SetOptions, 13 | ListOptions, 14 | GetStoreOptions, 15 | GetDeployStoreOptions, 16 | } from "@netlify/blobs"; 17 | 18 | const DRIVER_NAME = "netlify-blobs"; 19 | 20 | type GetOptions = { type?: BlobResponseType }; 21 | 22 | export type NetlifyStoreOptions = 23 | | NetlifyDeployStoreLegacyOptions 24 | | NetlifyDeployStoreOptions 25 | | NetlifyNamedStoreOptions; 26 | 27 | export interface ExtraOptions { 28 | /** If set to `true`, the store is scoped to the deploy. This means that it is only available from that deploy, and will be deleted or rolled-back alongside it. */ 29 | deployScoped?: boolean; 30 | } 31 | 32 | export interface NetlifyDeployStoreOptions 33 | extends GetDeployStoreOptions, 34 | ExtraOptions { 35 | name?: never; 36 | deployScoped: true; 37 | } 38 | 39 | export interface NetlifyDeployStoreLegacyOptions 40 | extends NetlifyDeployStoreOptions { 41 | // Added in v8.0.0. This ensures TS compatibility for older versions. 42 | region?: never; 43 | } 44 | 45 | export interface NetlifyNamedStoreOptions 46 | extends GetStoreOptions, 47 | ExtraOptions { 48 | name: string; 49 | deployScoped?: false; 50 | } 51 | 52 | export default defineDriver((options: NetlifyStoreOptions) => { 53 | const { deployScoped, name, ...opts } = options; 54 | let store: Store; 55 | 56 | const getClient = () => { 57 | if (!store) { 58 | if (deployScoped) { 59 | if (name) { 60 | throw createError( 61 | DRIVER_NAME, 62 | "deploy-scoped stores cannot have a name" 63 | ); 64 | } 65 | store = getDeployStore({ fetch, ...options }); 66 | } else { 67 | if (!name) { 68 | throw createRequiredError(DRIVER_NAME, "name"); 69 | } 70 | // Ensures that reserved characters are encoded 71 | store = getStore({ name: encodeURIComponent(name), fetch, ...opts }); 72 | } 73 | } 74 | return store; 75 | }; 76 | 77 | return { 78 | name: DRIVER_NAME, 79 | options, 80 | getInstance: getClient, 81 | async hasItem(key) { 82 | return getClient().getMetadata(key).then(Boolean); 83 | }, 84 | getItem: (key, tops?: GetOptions) => { 85 | // @ts-expect-error has trouble with the overloaded types 86 | return getClient().get(key, tops); 87 | }, 88 | getMeta(key) { 89 | return getClient().getMetadata(key); 90 | }, 91 | getItemRaw(key, topts?: GetOptions) { 92 | // @ts-expect-error has trouble with the overloaded types 93 | return getClient().get(key, { type: topts?.type ?? "arrayBuffer" }); 94 | }, 95 | async setItem(key, value, topts?: SetOptions) { 96 | // NOTE: this returns either Promise (pre-v10) or Promise (v10+) 97 | // TODO(serhalp): Allow drivers to return a value from `setItem`. The @netlify/blobs v10 98 | // functionality isn't usable without this. 99 | await getClient().set(key, value, topts); 100 | }, 101 | async setItemRaw( 102 | key, 103 | value: string | ArrayBuffer | Blob, 104 | topts?: SetOptions 105 | ) { 106 | // NOTE: this returns either Promise (pre-v10) or Promise (v10+) 107 | // See TODO above. 108 | await getClient().set(key, value, topts); 109 | }, 110 | removeItem(key) { 111 | return getClient().delete(key); 112 | }, 113 | async getKeys( 114 | base?: string, 115 | tops?: GetKeysOptions & Omit 116 | ) { 117 | return (await getClient().list({ ...tops, prefix: base })).blobs.map( 118 | (item) => item.key 119 | ); 120 | }, 121 | async clear(base?: string) { 122 | const client = getClient(); 123 | return Promise.allSettled( 124 | (await client.list({ prefix: base })).blobs.map((item) => 125 | client.delete(item.key) 126 | ) 127 | ).then(() => {}); 128 | }, 129 | }; 130 | }); 131 | -------------------------------------------------------------------------------- /src/drivers/redis.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver, joinKeys } from "./utils/index.ts"; 2 | import { Cluster, Redis } from "ioredis"; 3 | 4 | import type { 5 | ClusterOptions, 6 | ClusterNode, 7 | RedisOptions as _RedisOptions, 8 | } from "ioredis"; 9 | 10 | export interface RedisOptions extends _RedisOptions { 11 | /** 12 | * Optional prefix to use for all keys. Can be used for namespacing. 13 | */ 14 | base?: string; 15 | 16 | /** 17 | * Url to use for connecting to redis. Takes precedence over `host` option. Has the format `redis://:@:` 18 | */ 19 | url?: string; 20 | 21 | /** 22 | * List of redis nodes to use for cluster mode. Takes precedence over `url` and `host` options. 23 | */ 24 | cluster?: ClusterNode[]; 25 | 26 | /** 27 | * Options to use for cluster mode. 28 | */ 29 | clusterOptions?: ClusterOptions; 30 | 31 | /** 32 | * Default TTL for all items in seconds. 33 | */ 34 | ttl?: number; 35 | 36 | /** 37 | * How many keys to scan at once. 38 | * 39 | * [redis documentation](https://redis.io/docs/latest/commands/scan/#the-count-option) 40 | */ 41 | scanCount?: number; 42 | 43 | /** 44 | * Whether to initialize the redis instance immediately. 45 | * Otherwise, it will be initialized on the first read/write call. 46 | * @default false 47 | */ 48 | preConnect?: boolean; 49 | } 50 | 51 | const DRIVER_NAME = "redis"; 52 | 53 | export default defineDriver((opts: RedisOptions) => { 54 | let redisClient: Redis | Cluster; 55 | const getRedisClient = () => { 56 | if (redisClient) { 57 | return redisClient; 58 | } 59 | if (opts.cluster) { 60 | redisClient = new Redis.Cluster(opts.cluster, opts.clusterOptions); 61 | } else if (opts.url) { 62 | redisClient = new Redis(opts.url, opts); 63 | } else { 64 | redisClient = new Redis(opts); 65 | } 66 | return redisClient; 67 | }; 68 | 69 | const base = (opts.base || "").replace(/:$/, ""); 70 | const p = (...keys: string[]) => joinKeys(base, ...keys); // Prefix a key. Uses base for backwards compatibility 71 | const d = (key: string) => (base ? key.replace(`${base}:`, "") : key); // Deprefix a key 72 | 73 | if (opts.preConnect) { 74 | try { 75 | getRedisClient(); 76 | } catch (error) { 77 | console.error(error); 78 | } 79 | } 80 | 81 | const scan = async (pattern: string): Promise => { 82 | const client = getRedisClient(); 83 | const keys: string[] = []; 84 | let cursor = "0"; 85 | do { 86 | const [nextCursor, scanKeys] = opts.scanCount 87 | ? await client.scan(cursor, "MATCH", pattern, "COUNT", opts.scanCount) 88 | : await client.scan(cursor, "MATCH", pattern); 89 | cursor = nextCursor; 90 | keys.push(...scanKeys); 91 | } while (cursor !== "0"); 92 | return keys; 93 | }; 94 | 95 | return { 96 | name: DRIVER_NAME, 97 | options: opts, 98 | getInstance: getRedisClient, 99 | async hasItem(key) { 100 | return Boolean(await getRedisClient().exists(p(key))); 101 | }, 102 | async getItem(key) { 103 | const value = await getRedisClient().get(p(key)); 104 | return value ?? null; 105 | }, 106 | async getItems(items) { 107 | const keys = items.map((item) => p(item.key)); 108 | const data = await getRedisClient().mget(...keys); 109 | 110 | return keys.map((key, index) => { 111 | return { 112 | key: d(key), 113 | value: data[index] ?? null, 114 | }; 115 | }); 116 | }, 117 | async setItem(key, value, tOptions) { 118 | const ttl = tOptions?.ttl ?? opts.ttl; 119 | if (ttl) { 120 | await getRedisClient().set(p(key), value, "EX", ttl); 121 | } else { 122 | await getRedisClient().set(p(key), value); 123 | } 124 | }, 125 | async removeItem(key) { 126 | await getRedisClient().unlink(p(key)); 127 | }, 128 | async getKeys(base) { 129 | const keys = await scan(p(base, "*")); 130 | return keys.map((key) => d(key)); 131 | }, 132 | async clear(base) { 133 | const keys = await scan(p(base, "*")); 134 | if (keys.length === 0) { 135 | return; 136 | } 137 | await getRedisClient().unlink(keys); 138 | }, 139 | dispose() { 140 | return getRedisClient().disconnect(); 141 | }, 142 | }; 143 | }); 144 | -------------------------------------------------------------------------------- /src/drivers/fs.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, promises as fsp, Stats } from "node:fs"; 2 | import { resolve, relative, join, matchesGlob } from "node:path"; 3 | import type { FSWatcher, ChokidarOptions } from "chokidar"; 4 | import { 5 | createError, 6 | createRequiredError, 7 | defineDriver, 8 | } from "./utils/index.ts"; 9 | import { 10 | readFile, 11 | writeFile, 12 | readdirRecursive, 13 | rmRecursive, 14 | unlink, 15 | } from "./utils/node-fs.ts"; 16 | 17 | export interface FSStorageOptions { 18 | base?: string; 19 | ignore?: string[]; 20 | readOnly?: boolean; 21 | noClear?: boolean; 22 | watchOptions?: ChokidarOptions; 23 | } 24 | 25 | const PATH_TRAVERSE_RE = /\.\.:|\.\.$/; 26 | 27 | const DRIVER_NAME = "fs"; 28 | 29 | export default defineDriver((userOptions: FSStorageOptions = {}) => { 30 | if (!userOptions.base) { 31 | throw createRequiredError(DRIVER_NAME, "base"); 32 | } 33 | 34 | const base = resolve(userOptions.base); 35 | 36 | const ignorePatterns = userOptions.ignore || [ 37 | "**/node_modules/**", 38 | "**/.git/**", 39 | ]; 40 | const ignore = (path: string) => { 41 | return ignorePatterns.some((pattern) => matchesGlob(path, pattern)); 42 | }; 43 | 44 | const r = (key: string) => { 45 | if (PATH_TRAVERSE_RE.test(key)) { 46 | throw createError( 47 | DRIVER_NAME, 48 | `Invalid key: ${JSON.stringify(key)}. It should not contain .. segments` 49 | ); 50 | } 51 | const resolved = join(base, key.replace(/:/g, "/")); 52 | return resolved; 53 | }; 54 | 55 | let _watcher: FSWatcher | undefined; 56 | const _unwatch = async () => { 57 | if (_watcher) { 58 | await _watcher.close(); 59 | _watcher = undefined; 60 | } 61 | }; 62 | 63 | return { 64 | name: DRIVER_NAME, 65 | options: userOptions, 66 | flags: { 67 | maxDepth: true, 68 | }, 69 | hasItem(key) { 70 | return existsSync(r(key)); 71 | }, 72 | getItem(key) { 73 | return readFile(r(key), "utf8"); 74 | }, 75 | getItemRaw(key) { 76 | return readFile(r(key)); 77 | }, 78 | async getMeta(key) { 79 | const { atime, mtime, size, birthtime, ctime } = await fsp 80 | .stat(r(key)) 81 | .catch(() => ({}) as Stats); 82 | return { atime, mtime, size, birthtime, ctime }; 83 | }, 84 | setItem(key, value) { 85 | if (userOptions.readOnly) { 86 | return; 87 | } 88 | return writeFile(r(key), value, "utf8"); 89 | }, 90 | setItemRaw(key, value) { 91 | if (userOptions.readOnly) { 92 | return; 93 | } 94 | return writeFile(r(key), value); 95 | }, 96 | removeItem(key) { 97 | if (userOptions.readOnly) { 98 | return; 99 | } 100 | return unlink(r(key)); 101 | }, 102 | getKeys(_base, topts) { 103 | return readdirRecursive(r("."), ignore, topts?.maxDepth); 104 | }, 105 | async clear() { 106 | if (userOptions.readOnly || userOptions.noClear) { 107 | return; 108 | } 109 | await rmRecursive(r(".")); 110 | }, 111 | async dispose() { 112 | if (_watcher) { 113 | await _watcher.close(); 114 | } 115 | }, 116 | async watch(callback) { 117 | if (_watcher) { 118 | return _unwatch; 119 | } 120 | const { watch } = await import("chokidar"); 121 | await new Promise((resolve, reject) => { 122 | const watchOptions: ChokidarOptions = { 123 | ignoreInitial: true, 124 | ...userOptions.watchOptions, 125 | }; 126 | if (!watchOptions.ignored) { 127 | watchOptions.ignored = []; 128 | } else if (Array.isArray(watchOptions.ignored)) { 129 | watchOptions.ignored = [...watchOptions.ignored]; 130 | } else { 131 | watchOptions.ignored = [watchOptions.ignored]; 132 | } 133 | watchOptions.ignored.push(ignore); 134 | _watcher = watch(base, watchOptions) 135 | .on("ready", () => { 136 | resolve(); 137 | }) 138 | .on("error", reject) 139 | .on("all", (eventName, path) => { 140 | path = relative(base, path); 141 | if (eventName === "change" || eventName === "add") { 142 | callback("update", path); 143 | } else if (eventName === "unlink") { 144 | callback("remove", path); 145 | } 146 | }); 147 | }); 148 | return _unwatch; 149 | }, 150 | }; 151 | }); 152 | -------------------------------------------------------------------------------- /src/drivers/github.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createError, 3 | createRequiredError, 4 | defineDriver, 5 | } from "./utils/index.ts"; 6 | import { $fetch } from "ofetch"; 7 | import { withTrailingSlash, joinURL } from "./utils/path.ts"; 8 | 9 | export interface GithubOptions { 10 | /** 11 | * The name of the repository. (e.g. `username/my-repo`) 12 | * Required 13 | */ 14 | repo: string; 15 | /** 16 | * The branch to fetch. (e.g. `dev`) 17 | * @default "main" 18 | */ 19 | branch?: string; 20 | /** 21 | * @default "" 22 | */ 23 | dir?: string; 24 | /** 25 | * @default 600 26 | */ 27 | ttl?: number; 28 | /** 29 | * Github API token (recommended) 30 | */ 31 | token?: string; 32 | /** 33 | * @default "https://api.github.com" 34 | */ 35 | apiURL?: string; 36 | /** 37 | * @default "https://raw.githubusercontent.com" 38 | */ 39 | cdnURL?: string; 40 | } 41 | 42 | interface GithubFile { 43 | body?: string; 44 | meta: { 45 | sha: string; 46 | mode: string; 47 | size: number; 48 | }; 49 | } 50 | 51 | const defaultOptions: GithubOptions = { 52 | repo: "", 53 | branch: "main", 54 | ttl: 600, 55 | dir: "", 56 | apiURL: "https://api.github.com", 57 | cdnURL: "https://raw.githubusercontent.com", 58 | }; 59 | 60 | const DRIVER_NAME = "github"; 61 | 62 | export default defineDriver((_opts) => { 63 | const opts: GithubOptions = { ...defaultOptions, ..._opts }; 64 | const rawUrl = joinURL( 65 | opts.cdnURL!, 66 | [opts.repo, opts.branch!, opts.dir!].join("/") 67 | ); 68 | 69 | let files: Record = {}; 70 | let lastCheck = 0; 71 | let syncPromise: undefined | Promise; 72 | 73 | const syncFiles = async () => { 74 | if (!opts.repo) { 75 | throw createRequiredError(DRIVER_NAME, "repo"); 76 | } 77 | 78 | if (lastCheck + opts.ttl! * 1000 > Date.now()) { 79 | return; 80 | } 81 | 82 | if (!syncPromise) { 83 | syncPromise = fetchFiles(opts); 84 | } 85 | 86 | files = await syncPromise; 87 | lastCheck = Date.now(); 88 | syncPromise = undefined; 89 | }; 90 | 91 | return { 92 | name: DRIVER_NAME, 93 | options: opts, 94 | async getKeys() { 95 | await syncFiles(); 96 | return Object.keys(files); 97 | }, 98 | async hasItem(key) { 99 | await syncFiles(); 100 | return key in files; 101 | }, 102 | async getItem(key) { 103 | await syncFiles(); 104 | 105 | const item = files[key]; 106 | 107 | if (!item) { 108 | return null; 109 | } 110 | 111 | if (!item.body) { 112 | try { 113 | item.body = await $fetch(key.replace(/:/g, "/"), { 114 | baseURL: rawUrl, 115 | headers: opts.token 116 | ? { 117 | Authorization: `token ${opts.token}`, 118 | } 119 | : undefined, 120 | }); 121 | } catch (error) { 122 | throw createError( 123 | "github", 124 | `Failed to fetch \`${JSON.stringify(key)}\``, 125 | { cause: error } 126 | ); 127 | } 128 | } 129 | return item.body; 130 | }, 131 | async getMeta(key) { 132 | await syncFiles(); 133 | const item = files[key as keyof typeof files]; 134 | return item ? item.meta : null; 135 | }, 136 | }; 137 | }); 138 | 139 | async function fetchFiles(opts: GithubOptions) { 140 | const prefix = withTrailingSlash(opts.dir).replace(/^\//, ""); 141 | const files: Record = {}; 142 | try { 143 | const trees = await $fetch( 144 | `/repos/${opts.repo}/git/trees/${opts.branch}?recursive=1`, 145 | { 146 | baseURL: opts.apiURL, 147 | headers: { 148 | "User-Agent": "unstorage", 149 | ...(opts.token && { Authorization: `token ${opts.token}` }), 150 | }, 151 | } 152 | ); 153 | 154 | for (const node of trees.tree) { 155 | if (node.type !== "blob" || !node.path.startsWith(prefix)) { 156 | continue; 157 | } 158 | const key: string = node.path.slice(prefix.length).replace(/\//g, ":"); 159 | files[key] = { 160 | meta: { 161 | sha: node.sha, 162 | mode: node.mode, 163 | size: node.size, 164 | }, 165 | }; 166 | } 167 | 168 | return files; 169 | } catch (error) { 170 | throw createError(DRIVER_NAME, "Failed to fetch git tree", { 171 | cause: error, 172 | }); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/drivers/azure-cosmos.ts: -------------------------------------------------------------------------------- 1 | import { createRequiredError, defineDriver } from "./utils/index.ts"; 2 | import { Container, CosmosClient } from "@azure/cosmos"; 3 | import { DefaultAzureCredential } from "@azure/identity"; 4 | 5 | export interface AzureCosmosOptions { 6 | /** 7 | * CosmosDB endpoint in the format of https://.documents.azure.com:443/. 8 | */ 9 | endpoint: string; 10 | 11 | /** 12 | * CosmosDB account key. If not provided, the driver will use the DefaultAzureCredential (recommended). 13 | */ 14 | accountKey?: string; 15 | 16 | /** 17 | * The name of the database to use. Defaults to `unstorage`. 18 | * @default "unstorage" 19 | */ 20 | databaseName?: string; 21 | 22 | /** 23 | * The name of the container to use. Defaults to `unstorage`. 24 | * @default "unstorage" 25 | */ 26 | containerName?: string; 27 | } 28 | 29 | const DRIVER_NAME = "azure-cosmos"; 30 | 31 | export interface AzureCosmosItem { 32 | /** 33 | * The unstorage key as id of the item. 34 | */ 35 | id: string; 36 | 37 | /** 38 | * The unstorage value of the item. 39 | */ 40 | value: string; 41 | 42 | /** 43 | * The unstorage mtime metadata of the item. 44 | */ 45 | modified: string | Date; 46 | } 47 | 48 | export default defineDriver((opts: AzureCosmosOptions) => { 49 | let client: Container; 50 | const getCosmosClient = async () => { 51 | if (client) { 52 | return client; 53 | } 54 | if (!opts.endpoint) { 55 | throw createRequiredError(DRIVER_NAME, "endpoint"); 56 | } 57 | if (opts.accountKey) { 58 | const cosmosClient = new CosmosClient({ 59 | endpoint: opts.endpoint, 60 | key: opts.accountKey, 61 | }); 62 | const { database } = await cosmosClient.databases.createIfNotExists({ 63 | id: opts.databaseName || "unstorage", 64 | }); 65 | const { container } = await database.containers.createIfNotExists({ 66 | id: opts.containerName || "unstorage", 67 | }); 68 | client = container; 69 | } else { 70 | const credential = new DefaultAzureCredential(); 71 | const cosmosClient = new CosmosClient({ 72 | endpoint: opts.endpoint, 73 | aadCredentials: credential, 74 | }); 75 | const { database } = await cosmosClient.databases.createIfNotExists({ 76 | id: opts.databaseName || "unstorage", 77 | }); 78 | const { container } = await database.containers.createIfNotExists({ 79 | id: opts.containerName || "unstorage", 80 | }); 81 | client = container; 82 | } 83 | return client; 84 | }; 85 | 86 | return { 87 | name: DRIVER_NAME, 88 | options: opts, 89 | getInstance: getCosmosClient, 90 | async hasItem(key) { 91 | const item = await (await getCosmosClient()) 92 | .item(key) 93 | .read(); 94 | return item.resource ? true : false; 95 | }, 96 | async getItem(key) { 97 | const item = await (await getCosmosClient()) 98 | .item(key) 99 | .read(); 100 | return item.resource ? item.resource.value : null; 101 | }, 102 | async setItem(key, value) { 103 | const modified = new Date(); 104 | await ( 105 | await getCosmosClient() 106 | ).items.upsert( 107 | { id: key, value, modified }, 108 | { consistencyLevel: "Session" } 109 | ); 110 | }, 111 | async removeItem(key) { 112 | await (await getCosmosClient()) 113 | .item(key) 114 | .delete({ consistencyLevel: "Session" }); 115 | }, 116 | async getKeys() { 117 | const iterator = (await getCosmosClient()).items.query( 118 | `SELECT { id } from c` 119 | ); 120 | return (await iterator.fetchAll()).resources.map((item) => item.id); 121 | }, 122 | async getMeta(key) { 123 | const item = await (await getCosmosClient()) 124 | .item(key) 125 | .read(); 126 | return { 127 | mtime: item.resource?.modified 128 | ? new Date(item.resource.modified) 129 | : undefined, 130 | }; 131 | }, 132 | async clear() { 133 | const iterator = (await getCosmosClient()).items.query( 134 | `SELECT { id } from c` 135 | ); 136 | const items = (await iterator.fetchAll()).resources; 137 | for (const item of items) { 138 | await (await getCosmosClient()) 139 | .item(item.id) 140 | .delete({ consistencyLevel: "Session" }); 141 | } 142 | }, 143 | }; 144 | }); 145 | -------------------------------------------------------------------------------- /src/drivers/azure-key-vault.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createError, 3 | createRequiredError, 4 | defineDriver, 5 | } from "./utils/index.ts"; 6 | import { 7 | SecretClient, 8 | type SecretClientOptions, 9 | } from "@azure/keyvault-secrets"; 10 | import { DefaultAzureCredential } from "@azure/identity"; 11 | 12 | export interface AzureKeyVaultOptions { 13 | /** 14 | * The name of the key vault to use. 15 | */ 16 | vaultName: string; 17 | 18 | /** 19 | * Version of the Azure Key Vault service to use. Defaults to 7.3. 20 | * @default '7.3' 21 | */ 22 | serviceVersion?: SecretClientOptions["serviceVersion"]; 23 | 24 | /** 25 | * The number of entries to retrieve per request. Impacts getKeys() and clear() performance. Maximum value is 25. 26 | * @default 25 27 | */ 28 | pageSize?: number; 29 | } 30 | 31 | const DRIVER_NAME = "azure-key-vault"; 32 | 33 | export default defineDriver((opts: AzureKeyVaultOptions) => { 34 | let keyVaultClient: SecretClient; 35 | const getKeyVaultClient = () => { 36 | if (keyVaultClient) { 37 | return keyVaultClient; 38 | } 39 | const { vaultName = null, serviceVersion = "7.3", pageSize = 25 } = opts; 40 | if (!vaultName) { 41 | throw createRequiredError(DRIVER_NAME, "vaultName"); 42 | } 43 | if (pageSize > 25) { 44 | throw createError(DRIVER_NAME, "`pageSize` cannot be greater than `25`"); 45 | } 46 | const credential = new DefaultAzureCredential(); 47 | const url = `https://${vaultName}.vault.azure.net`; 48 | keyVaultClient = new SecretClient(url, credential, { serviceVersion }); 49 | return keyVaultClient; 50 | }; 51 | 52 | return { 53 | name: DRIVER_NAME, 54 | options: opts, 55 | getInstance: getKeyVaultClient, 56 | async hasItem(key) { 57 | try { 58 | await getKeyVaultClient().getSecret(encode(key)); 59 | return true; 60 | } catch { 61 | return false; 62 | } 63 | }, 64 | async getItem(key) { 65 | try { 66 | const secret = await getKeyVaultClient().getSecret(encode(key)); 67 | return secret.value; 68 | } catch { 69 | return null; 70 | } 71 | }, 72 | async setItem(key, value) { 73 | await getKeyVaultClient().setSecret(encode(key), value); 74 | }, 75 | async removeItem(key) { 76 | const poller = await getKeyVaultClient().beginDeleteSecret(encode(key)); 77 | await poller.pollUntilDone(); 78 | await getKeyVaultClient().purgeDeletedSecret(encode(key)); 79 | }, 80 | async getKeys() { 81 | const secrets = getKeyVaultClient() 82 | .listPropertiesOfSecrets() 83 | .byPage({ maxPageSize: opts.pageSize || 25 }); 84 | const keys: string[] = []; 85 | for await (const page of secrets) { 86 | const pageKeys = page.map((secret) => decode(secret.name)); 87 | keys.push(...pageKeys); 88 | } 89 | return keys; 90 | }, 91 | async getMeta(key) { 92 | const secret = await getKeyVaultClient().getSecret(encode(key)); 93 | return { 94 | mtime: secret.properties.updatedOn, 95 | birthtime: secret.properties.createdOn, 96 | expireTime: secret.properties.expiresOn, 97 | }; 98 | }, 99 | async clear() { 100 | const secrets = getKeyVaultClient() 101 | .listPropertiesOfSecrets() 102 | .byPage({ maxPageSize: opts.pageSize || 25 }); 103 | for await (const page of secrets) { 104 | const deletionPromises = page.map(async (secret) => { 105 | const poller = await getKeyVaultClient().beginDeleteSecret( 106 | secret.name 107 | ); 108 | await poller.pollUntilDone(); 109 | await getKeyVaultClient().purgeDeletedSecret(secret.name); 110 | }); 111 | await Promise.all(deletionPromises); 112 | } 113 | }, 114 | }; 115 | }); 116 | 117 | const base64Map: { [key: string]: string } = { 118 | "=": "-e-", 119 | "+": "-p-", 120 | "/": "-s-", 121 | }; 122 | 123 | function encode(value: string): string { 124 | let encoded = Buffer.from(value).toString("base64"); 125 | for (const key in base64Map) { 126 | encoded = encoded.replace( 127 | new RegExp(key.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&"), "g"), 128 | base64Map[key]! 129 | ); 130 | } 131 | return encoded; 132 | } 133 | 134 | function decode(value: string): string { 135 | let decoded = value; 136 | const search = new RegExp(Object.values(base64Map).join("|"), "g"); 137 | decoded = decoded.replace(search, (match) => { 138 | return Object.keys(base64Map).find((key) => base64Map[key] === match)!; 139 | }); 140 | return Buffer.from(decoded, "base64").toString(); 141 | } 142 | -------------------------------------------------------------------------------- /src/drivers/vercel-blob.ts: -------------------------------------------------------------------------------- 1 | import { del, head, list, put } from "@vercel/blob"; 2 | import { 3 | defineDriver, 4 | normalizeKey, 5 | joinKeys, 6 | createError, 7 | } from "./utils/index.ts"; 8 | 9 | export interface VercelBlobOptions { 10 | /** 11 | * Whether the blob should be publicly accessible. (required, must be "public") 12 | */ 13 | access: "public"; 14 | 15 | /** 16 | * Prefix to prepend to all keys. Can be used for namespacing. 17 | */ 18 | base?: string; 19 | 20 | /** 21 | * Rest API Token to use for connecting to your Vercel Blob store. 22 | * If not provided, it will be read from the environment variable `BLOB_READ_WRITE_TOKEN`. 23 | */ 24 | token?: string; 25 | 26 | /** 27 | * Prefix to use for token environment variable name. 28 | * Default is `BLOB` (env name = `BLOB_READ_WRITE_TOKEN`). 29 | */ 30 | envPrefix?: string; 31 | } 32 | 33 | const DRIVER_NAME = "vercel-blob"; 34 | 35 | export default defineDriver((opts) => { 36 | const optsBase = normalizeKey(opts?.base); 37 | 38 | const r = (...keys: string[]) => 39 | joinKeys(optsBase, ...keys).replace(/:/g, "/"); 40 | 41 | const envName = `${opts.envPrefix || "BLOB"}_READ_WRITE_TOKEN`; 42 | 43 | const getToken = () => { 44 | if (opts.access !== "public") { 45 | throw createError(DRIVER_NAME, `You must set { access: "public" }`); 46 | } 47 | const token = opts.token || globalThis.process?.env?.[envName]; 48 | if (!token) { 49 | throw createError( 50 | DRIVER_NAME, 51 | `Missing token. Set ${envName} env or token config.` 52 | ); 53 | } 54 | return token; 55 | }; 56 | 57 | const get = async (key: string) => { 58 | const { blobs } = await list({ 59 | token: getToken(), 60 | prefix: r(key), 61 | }); 62 | const blob = blobs.find((item) => item.pathname === r(key)); 63 | return blob; 64 | }; 65 | 66 | return { 67 | name: DRIVER_NAME, 68 | options: opts, 69 | async hasItem(key: string) { 70 | const blob = await get(key); 71 | return !!blob; 72 | }, 73 | async getItem(key) { 74 | const blob = await get(key); 75 | return blob ? fetch(blob.url).then((res) => res.text()) : null; 76 | }, 77 | async getItemRaw(key) { 78 | const blob = await get(key); 79 | return blob ? fetch(blob.url).then((res) => res.arrayBuffer()) : null; 80 | }, 81 | async getMeta(key) { 82 | const blob = await get(key); 83 | if (!blob) return null; 84 | const blobHead = await head(blob.url, { 85 | token: getToken(), 86 | }); 87 | if (!blobHead) return null; 88 | return { 89 | mtime: blobHead.uploadedAt, 90 | ...blobHead, 91 | }; 92 | }, 93 | async setItem(key, value, opts) { 94 | await put(r(key), value, { 95 | access: "public", 96 | addRandomSuffix: false, 97 | token: getToken(), 98 | ...opts, 99 | }); 100 | }, 101 | async setItemRaw(key, value, opts) { 102 | await put(r(key), value, { 103 | access: "public", 104 | addRandomSuffix: false, 105 | token: getToken(), 106 | ...opts, 107 | }); 108 | }, 109 | async removeItem(key: string) { 110 | const blob = await get(key); 111 | if (blob) await del(blob.url, { token: getToken() }); 112 | }, 113 | async getKeys(base: string) { 114 | const blobs: any[] = []; 115 | let cursor: string | undefined = undefined; 116 | do { 117 | const listBlobResult: Awaited> = await list({ 118 | token: getToken(), 119 | cursor, 120 | prefix: r(base), 121 | }); 122 | cursor = listBlobResult.cursor; 123 | for (const blob of listBlobResult.blobs) { 124 | blobs.push(blob); 125 | } 126 | } while (cursor); 127 | return blobs.map((blob) => 128 | blob.pathname.replace( 129 | new RegExp(`^${optsBase.replace(/:/g, "/")}/`), 130 | "" 131 | ) 132 | ); 133 | }, 134 | async clear(base) { 135 | let cursor: string | undefined = undefined; 136 | const blobs: any[] = []; 137 | do { 138 | const listBlobResult: Awaited> = await list({ 139 | token: getToken(), 140 | cursor, 141 | prefix: r(base), 142 | }); 143 | blobs.push(...listBlobResult.blobs); 144 | cursor = listBlobResult.cursor; 145 | } while (cursor); 146 | 147 | if (blobs.length > 0) { 148 | await del( 149 | blobs.map((blob) => blob.url), 150 | { 151 | token: getToken(), 152 | } 153 | ); 154 | } 155 | }, 156 | }; 157 | }); 158 | -------------------------------------------------------------------------------- /test/server.test.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { describe, it, expect } from "vitest"; 3 | import { serve } from "srvx"; 4 | import { $fetch } from "ofetch"; 5 | import { createStorage } from "../src/index.ts"; 6 | import { createStorageHandler } from "../src/server.ts"; 7 | import fsDriver from "../src/drivers/fs.ts"; 8 | import httpDriver from "../src/drivers/http.ts"; 9 | 10 | describe("server", () => { 11 | it("basic", async () => { 12 | const storage = createTestStorage(); 13 | const storageServer = createStorageHandler(storage, { 14 | authorize(req) { 15 | if (req.type === "read" && req.key.startsWith("private:")) { 16 | throw new Error("Unauthorized Read"); 17 | } 18 | }, 19 | }); 20 | const server = await serve({ 21 | port: 0, 22 | fetch: storageServer, 23 | }); 24 | 25 | const fetchStorage = (url: string, options?: any) => 26 | $fetch(url, { baseURL: server.url!, ...options }); 27 | 28 | const remoteStorage = createStorage({ 29 | driver: httpDriver({ base: server.url! }), 30 | }); 31 | 32 | expect(await fetchStorage("foo/", {})).toMatchObject([]); 33 | 34 | await storage.setItem("foo/bar", "bar"); 35 | await storage.setMeta("foo/bar", { mtime: new Date() }); 36 | expect(await fetchStorage("foo/bar")).toBe("bar"); 37 | 38 | expect( 39 | await fetchStorage("foo/bar", { method: "PUT", body: "updated" }) 40 | ).toBe("OK"); 41 | expect(await fetchStorage("foo/bar")).toBe("updated"); 42 | expect(await fetchStorage("/")).toMatchObject(["foo/bar"]); 43 | 44 | expect(await fetchStorage("foo/bar", { method: "DELETE" })).toBe("OK"); 45 | expect(await fetchStorage("foo/bar/", {})).toMatchObject([]); 46 | 47 | await expect( 48 | fetchStorage("/non", { method: "GET" }).catch((error) => { 49 | throw error.data; 50 | }) 51 | ).rejects.toMatchObject({ 52 | status: 404, 53 | message: "KV value not found", 54 | }); 55 | 56 | await expect( 57 | fetchStorage("private/foo/bar", { method: "GET" }).catch((error) => { 58 | throw error.data; 59 | }) 60 | ).rejects.toMatchObject({ 61 | status: 401, 62 | statusText: "Unauthorized Read", 63 | message: "Unauthorized Read", 64 | }); 65 | 66 | // TTL 67 | await storage.setItem("ttl", "ttl", { ttl: 1000 }); 68 | expect(await storage.getMeta("ttl")).toMatchObject({ ttl: 1000 }); 69 | expect(await remoteStorage.getMeta("ttl")).toMatchObject({ ttl: 1000 }); 70 | 71 | await server.close(); 72 | }); 73 | 74 | it("properly encodes raw items", async () => { 75 | const storage = createStorage({ 76 | driver: fsDriver({ base: "./test/fs-storage" }), 77 | }); 78 | const storageServer = createStorageHandler(storage); 79 | const server = await serve({ 80 | port: 0, 81 | fetch: storageServer, 82 | }); 83 | 84 | const fetchStorage = (url: string, options?: any) => 85 | $fetch(url, { baseURL: server.url!, ...options }); 86 | 87 | const file = await readFile("./test/test.png"); 88 | 89 | await storage.setItemRaw("1.png", file); 90 | await fetchStorage("2.png", { 91 | method: "PUT", 92 | body: file, 93 | headers: { 94 | "content-type": "application/octet-stream", 95 | }, 96 | }); 97 | const storedFileNode = await readFile("./test/fs-storage/1.png"); 98 | const storedFileFetch = await readFile("./test/fs-storage/2.png"); 99 | 100 | expect(storedFileNode).toStrictEqual(file); 101 | expect(storedFileFetch).toStrictEqual(file); 102 | expect(storedFileFetch).toStrictEqual(storedFileNode); 103 | 104 | await server.close(); 105 | }); 106 | }); 107 | 108 | function createTestStorage() { 109 | const data = new Map(); 110 | const ttl = new Map(); 111 | const storage = createStorage({ 112 | driver: { 113 | hasItem(key) { 114 | return data.has(key); 115 | }, 116 | getItem(key) { 117 | return data.get(key) ?? null; 118 | }, 119 | getItemRaw(key) { 120 | return data.get(key) ?? null; 121 | }, 122 | setItem(key, value, opts) { 123 | data.set(key, value); 124 | if (opts?.ttl) { 125 | ttl.set(key, opts.ttl); 126 | } 127 | }, 128 | setItemRaw(key, value, opts) { 129 | data.set(key, value); 130 | if (opts?.ttl) { 131 | ttl.set(key, opts.ttl); 132 | } 133 | }, 134 | getMeta(key) { 135 | return { 136 | ttl: ttl.get(key), 137 | }; 138 | }, 139 | removeItem(key) { 140 | data.delete(key); 141 | }, 142 | getKeys() { 143 | return [...data.keys()]; 144 | }, 145 | clear() { 146 | data.clear(); 147 | }, 148 | dispose() { 149 | data.clear(); 150 | }, 151 | }, 152 | }); 153 | 154 | return storage; 155 | } 156 | -------------------------------------------------------------------------------- /src/drivers/azure-app-configuration.ts: -------------------------------------------------------------------------------- 1 | import { defineDriver, createRequiredError } from "./utils/index.ts"; 2 | import { AppConfigurationClient } from "@azure/app-configuration"; 3 | import { DefaultAzureCredential } from "@azure/identity"; 4 | 5 | export interface AzureAppConfigurationOptions { 6 | /** 7 | * Optional prefix for keys. This can be used to isolate keys from different applications in the same Azure App Configuration instance. E.g. "app01" results in keys like "app01:foo" and "app01:bar". 8 | * @default null 9 | */ 10 | prefix?: string; 11 | 12 | /** 13 | * Optional label for keys. If not provided, all keys will be created and listed without labels. This can be used to isolate keys from different environments in the same Azure App Configuration instance. E.g. "dev" results in keys like "foo" and "bar" with the label "dev". 14 | * @default '\0' 15 | */ 16 | label?: string; 17 | 18 | /** 19 | * Optional endpoint to use when connecting to Azure App Configuration. If not provided, the appConfigName option must be provided. If both are provided, the endpoint option takes precedence. 20 | * @default null 21 | */ 22 | endpoint?: string; 23 | 24 | /** 25 | * Optional name of the Azure App Configuration instance to connect to. If not provided, the endpoint option must be provided. If both are provided, the endpoint option takes precedence. 26 | * @default null 27 | */ 28 | appConfigName?: string; 29 | 30 | /** 31 | * Optional connection string to use when connecting to Azure App Configuration. If not provided, the endpoint option must be provided. If both are provided, the endpoint option takes precedence. 32 | * @default null 33 | */ 34 | connectionString?: string; 35 | } 36 | 37 | const DRIVER_NAME = "azure-app-configuration"; 38 | 39 | export default defineDriver((opts: AzureAppConfigurationOptions = {}) => { 40 | const labelFilter = opts.label || "\0"; 41 | const keyFilter = opts.prefix ? `${opts.prefix}:*` : "*"; 42 | const p = (key: string) => (opts.prefix ? `${opts.prefix}:${key}` : key); // Prefix a key 43 | const d = (key: string) => (opts.prefix ? key.replace(opts.prefix, "") : key); // Deprefix a key 44 | 45 | let client: AppConfigurationClient; 46 | const getClient = () => { 47 | if (client) { 48 | return client; 49 | } 50 | if (!opts.endpoint && !opts.appConfigName && !opts.connectionString) { 51 | throw createRequiredError(DRIVER_NAME, [ 52 | "endpoint", 53 | "appConfigName", 54 | "connectionString", 55 | ]); 56 | } 57 | const appConfigEndpoint = 58 | opts.endpoint || `https://${opts.appConfigName}.azconfig.io`; 59 | if (opts.connectionString) { 60 | client = new AppConfigurationClient(opts.connectionString); 61 | } else { 62 | const credential = new DefaultAzureCredential(); 63 | client = new AppConfigurationClient(appConfigEndpoint, credential); 64 | } 65 | return client; 66 | }; 67 | 68 | return { 69 | name: DRIVER_NAME, 70 | options: opts, 71 | getInstance: getClient, 72 | async hasItem(key) { 73 | try { 74 | await getClient().getConfigurationSetting({ 75 | key: p(key), 76 | label: opts.label, 77 | }); 78 | return true; 79 | } catch { 80 | return false; 81 | } 82 | }, 83 | async getItem(key) { 84 | try { 85 | const setting = await getClient().getConfigurationSetting({ 86 | key: p(key), 87 | label: opts.label, 88 | }); 89 | return setting.value; 90 | } catch { 91 | return null; 92 | } 93 | }, 94 | async setItem(key, value) { 95 | await getClient().setConfigurationSetting({ 96 | key: p(key), 97 | value, 98 | label: opts.label, 99 | }); 100 | return; 101 | }, 102 | async removeItem(key) { 103 | await getClient().deleteConfigurationSetting({ 104 | key: p(key), 105 | label: opts.label, 106 | }); 107 | return; 108 | }, 109 | async getKeys() { 110 | const settings = getClient().listConfigurationSettings({ 111 | keyFilter, 112 | labelFilter, 113 | fields: ["key", "value", "label"], 114 | }); 115 | const keys: string[] = []; 116 | for await (const setting of settings) { 117 | keys.push(d(setting.key)); 118 | } 119 | return keys; 120 | }, 121 | async getMeta(key) { 122 | const setting = await getClient().getConfigurationSetting({ 123 | key: p(key), 124 | label: opts.label, 125 | }); 126 | return { 127 | mtime: setting.lastModified, 128 | etag: setting.etag, 129 | tags: setting.tags, 130 | }; 131 | }, 132 | async clear() { 133 | const settings = getClient().listConfigurationSettings({ 134 | keyFilter, 135 | labelFilter, 136 | fields: ["key", "value", "label"], 137 | }); 138 | for await (const setting of settings) { 139 | await getClient().deleteConfigurationSetting({ 140 | key: setting.key, 141 | label: setting.label, 142 | }); 143 | } 144 | }, 145 | }; 146 | }); 147 | -------------------------------------------------------------------------------- /docs/2.drivers/vercel.md: -------------------------------------------------------------------------------- 1 | --- 2 | icon: gg:vercel 3 | --- 4 | 5 | # Vercel 6 | 7 | ## Vercel Runtime Cache 8 | 9 | > Cache data within Vercel Functions using the Runtime Cache API. 10 | 11 | ::read-more{to="https://vercel.com/docs/functions"} 12 | Learn more about Vercel Functions and Runtime Cache. 13 | :: 14 | 15 | ### Usage 16 | 17 | **Driver name:** `vercel-runtime-cache` 18 | 19 | ```js 20 | import { createStorage } from "unstorage"; 21 | import vercelRuntimeCacheDriver from "unstorage/drivers/vercel-runtime-cache"; 22 | 23 | const storage = createStorage({ 24 | driver: vercelRuntimeCacheDriver({ 25 | // base: "app", 26 | // ttl: 60, // seconds 27 | // tags: ["v1"], 28 | }), 29 | }); 30 | ``` 31 | 32 | **Optional step:** To allow using outside of vercel functions, install `@vercel/functions` in your project: 33 | 34 | :pm-install{name="@vercel/functions"} 35 | 36 | ### Options 37 | 38 | - `base`: Optional prefix to use for all keys (namespacing). 39 | - `ttl`: Default TTL for all items in seconds. 40 | - `tags`: Default tags to apply to all cache entries (Note: Will be merged with per-call option tags). 41 | 42 | ### Per-call options 43 | 44 | - `ttl`: Add TTL (in seconds) for this `setItem` call. 45 | - `tags`: Apply tags to this `setItem` call. 46 | 47 | **Example:** 48 | 49 | ```js 50 | await storage.setItem("user:123", JSON.stringify({ name: "Ana" }), { 51 | ttl: 3600, 52 | tags: ["user:123"], 53 | }); 54 | ``` 55 | 56 | **To expire by tags:** 57 | 58 | ```js 59 | await storage.clear("", { tags: ["user:123"] }); 60 | ``` 61 | 62 | ### Limitations 63 | 64 | - `getKeys`: The runtime cache API does not support listing keys; this returns `[]`. 65 | - `clear`: The runtime cache API does not support clearing by base; only tag-based expiration is supported. 66 | - Metadata: Runtime cache does not expose metadata; `getMeta` is not implemented. 67 | - Persistence: This is not a persistent store; it’s intended for request-time caching inside Vercel Functions. 68 | 69 | > [!NOTE] 70 | > The Unstorage driver does not hash keys by default. To replicate the same behavior in `@vercel/functions` when using `getCache`, set the `keyHashFunction: (key) => key` option. 71 | 72 | ## Vercel KV 73 | 74 | > Store data in a Vercel KV Store. 75 | 76 | ::read-more{to="https://vercel.com/docs/storage/vercel-kv"} 77 | Learn more about Vercel KV. 78 | :: 79 | 80 | ### Usage 81 | 82 | **Driver name:** `vercel-kv` 83 | 84 | > [!NOTE] 85 | > Please check [Vercel KV Limits](https://vercel.com/docs/storage/vercel-kv/limits). 86 | 87 | ```js 88 | import { createStorage } from "unstorage"; 89 | import vercelKVDriver from "unstorage/drivers/vercel-kv"; 90 | 91 | const storage = createStorage({ 92 | driver: vercelKVDriver({ 93 | // url: "https://.kv.vercel-storage.com", // KV_REST_API_URL 94 | // token: "", // KV_REST_API_TOKEN 95 | // base: "test", 96 | // env: "KV", 97 | // ttl: 60, // in seconds 98 | }), 99 | }); 100 | ``` 101 | 102 | To use, you will need to install `@vercel/kv` dependency in your project: 103 | 104 | ```json 105 | { 106 | "dependencies": { 107 | "@vercel/kv": "latest" 108 | } 109 | } 110 | ``` 111 | 112 | **Note:** For driver options type support, you might need to install `@upstash/redis` dev dependency as well. 113 | 114 | **Options:** 115 | 116 | - `url`: Rest API URL to use for connecting to your Vercel KV store. Default is `KV_REST_API_URL`. 117 | - `token`: Rest API Token to use for connecting to your Vercel KV store. Default is `KV_REST_API_TOKEN`. 118 | - `base`: [optional] Prefix to use for all keys. Can be used for namespacing. 119 | - `env`: [optional] Flag to customize environment variable prefix (Default is `KV`). Set to `false` to disable env inference for `url` and `token` options. 120 | - `scanCount`: How many keys to scan at once. 121 | 122 | See [@upstash/redis](https://docs.upstash.com/redis/sdks/javascriptsdk/advanced) for all available options. 123 | 124 | ## Vercel Blob 125 | 126 | > Store data in a Vercel Blob Store. 127 | 128 | ::read-more{to="https://vercel.com/docs/storage/vercel-blob"} 129 | Learn more about Vercel Blob. 130 | :: 131 | 132 | ::warning 133 | Currently Vercel Blob stores all data with public access. 134 | :: 135 | 136 | ### Usage 137 | 138 | **Driver name:** `vercel-blob` 139 | 140 | To use, you will need to install [`@vercel/blob`](https://www.npmjs.com/package/@vercel/blob) dependency in your project: 141 | 142 | :pm-install{name="@vercel/blob"} 143 | 144 | ```js 145 | import { createStorage } from "unstorage"; 146 | import vercelBlobDriver from "unstorage/drivers/vercel-blob"; 147 | 148 | const storage = createStorage({ 149 | driver: vercelBlobDriver({ 150 | access: "public", // Required! Beware that stored data is publicly accessible. 151 | // token: "", // or set BLOB_READ_WRITE_TOKEN 152 | // base: "unstorage", 153 | // envPrefix: "BLOB", 154 | }), 155 | }); 156 | ``` 157 | 158 | **Options:** 159 | 160 | - `access`: Whether the blob should be publicly accessible. (required, must be `public`) 161 | - `base`: Prefix to prepend to all keys. Can be used for namespacing. 162 | - `token`: Rest API token to use for connecting to your Vercel Blob store. If not provided, it will be read from the environment variable `BLOB_READ_WRITE_TOKEN`. 163 | - `envPrefix`: Prefix to use for token environment variable name. Default is `BLOB` (env name = `BLOB_READ_WRITE_TOKEN`). 164 | --------------------------------------------------------------------------------