diff --git a/docs/2.drivers/0.index.md b/docs/2.drivers/0.index.md index e07eead06..bde505555 100644 --- a/docs/2.drivers/0.index.md +++ b/docs/2.drivers/0.index.md @@ -135,6 +135,15 @@ icon: icon-park-outline:hard-disk :: ::card --- + icon: simple-icons:bun + to: /drivers/bun-redis + title: Bun Redis + color: gray + --- + Store data in Redis using Bun's native RedisClient. + :: + ::card + --- icon: ph:database to: /drivers/database title: SQL Database diff --git a/docs/2.drivers/bun-redis.md b/docs/2.drivers/bun-redis.md new file mode 100644 index 000000000..c3c901264 --- /dev/null +++ b/docs/2.drivers/bun-redis.md @@ -0,0 +1,76 @@ +--- +icon: simple-icons:bun +--- + +# Bun Redis + +> Store data in Redis using Bun's native RedisClient. + +## Usage + +**Driver name:** `bun-redis` + +::read-more{to="https://bun.sh/docs/runtime/redis"} +Learn more about Bun's Redis client. +:: + +::note +This driver uses Bun's native [`RedisClient`](https://bun.sh/docs/runtime/redis) for high-performance Redis operations with automatic reconnection, connection pooling, and auto-pipelining. +:: + +::warning +This driver requires the [Bun runtime](https://bun.sh). It will not work in Node.js or other JavaScript runtimes. +:: + +Usage with Redis URL: + +```js +import { createStorage } from "unstorage"; +import bunRedisDriver from "unstorage/drivers/bun-redis"; + +const storage = createStorage({ + driver: bunRedisDriver({ + base: "unstorage", + url: "redis://localhost:6379", + }), +}); +``` + +Usage with connection options: + +```js +const storage = createStorage({ + driver: bunRedisDriver({ + base: "unstorage", + url: "redis://localhost:6379", + connectionTimeout: 10000, + autoReconnect: true, + maxRetries: 10, + enableAutoPipelining: true, + }), +}); +``` + +Usage without URL (uses environment variables `REDIS_URL` or `VALKEY_URL`): + +```js +const storage = createStorage({ + driver: bunRedisDriver({ + base: "unstorage", + }), +}); +``` + +**Options:** + +- `base`: Optional prefix to use for all keys. Can be used for namespacing. +- `url`: Url to use for connecting to redis. Takes precedence over other connection options. Has the format `redis://:@:` +- `ttl`: Default TTL for all items in **seconds**. +- `scanCount`: How many keys to scan at once ([redis documentation](https://redis.io/docs/latest/commands/scan/#the-count-option)). +- `preConnect`: Whether to initialize the redis instance immediately. Otherwise, it will be initialized on the first read/write call. Default: `false`. + +See [Bun RedisClient options](https://bun.sh/docs/runtime/redis) for all available connection options. + +**Transaction options:** + +- `ttl`: Supported for `setItem(key, value, { ttl: number /* seconds */ })` diff --git a/package.json b/package.json index dca0ecc1d..1a55f7ca3 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "uploadthing": "^7.7.4", "vite": "^7.2.4", "vitest": "^4.0.12", - "wrangler": "^4.49.1" + "wrangler": "^4.49.1", + "bun-types": "^1.3.0" }, "peerDependencies": { "@azure/app-configuration": "^1.9.0", @@ -108,7 +109,8 @@ "lru-cache": "^11.2.2", "mongodb": "^6|^7", "ofetch": "*", - "uploadthing": "^7.7.4" + "uploadthing": "^7.7.4", + "bun-types": "^1.3.0" }, "peerDependenciesMeta": { "@azure/app-configuration": { @@ -179,6 +181,9 @@ }, "uploadthing": { "optional": true + }, + "bun-types": { + "optional": true } }, "packageManager": "pnpm@10.23.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd89ee0f1..30f03582e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: azurite: specifier: ^3.35.0 version: 3.35.0 + bun-types: + specifier: ^1.3.0 + version: 1.3.4 changelogen: specifier: ^0.6.2 version: 0.6.2(magicast@0.5.1) @@ -2164,6 +2167,9 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} + bun-types@1.3.4: + resolution: {integrity: sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -6742,6 +6748,10 @@ snapshots: builtin-modules@5.0.0: {} + bun-types@1.3.4: + dependencies: + '@types/node': 24.10.1 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 diff --git a/src/_drivers.ts b/src/_drivers.ts index 13ac1f028..791fb23da 100644 --- a/src/_drivers.ts +++ b/src/_drivers.ts @@ -6,6 +6,7 @@ import type { AzureCosmosOptions as AzureCosmosOptions } from "unstorage/drivers import type { AzureKeyVaultOptions as AzureKeyVaultOptions } from "unstorage/drivers/azure-key-vault"; import type { AzureStorageBlobOptions as AzureStorageBlobOptions } from "unstorage/drivers/azure-storage-blob"; import type { AzureStorageTableOptions as AzureStorageTableOptions } from "unstorage/drivers/azure-storage-table"; +import type { RedisOptions as BunRedisOptions } from "unstorage/drivers/bun-redis"; import type { CapacitorPreferencesOptions as CapacitorPreferencesOptions } from "unstorage/drivers/capacitor-preferences"; import type { KVOptions as CloudflareKVBindingOptions } from "unstorage/drivers/cloudflare-kv-binding"; import type { KVHTTPOptions as CloudflareKVHttpOptions } from "unstorage/drivers/cloudflare-kv-http"; @@ -33,7 +34,7 @@ import type { VercelBlobOptions as VercelBlobOptions } from "unstorage/drivers/v import type { VercelKVOptions as VercelKVOptions } from "unstorage/drivers/vercel-kv"; import type { VercelCacheOptions as VercelRuntimeCacheOptions } from "unstorage/drivers/vercel-runtime-cache"; -export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV" | "vercel-runtime-cache" | "vercelRuntimeCache"; +export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "bun-redis" | "bunRedis" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV" | "vercel-runtime-cache" | "vercelRuntimeCache"; export type BuiltinDriverOptions = { "azure-app-configuration": AzureAppConfigurationOptions; @@ -46,6 +47,8 @@ export type BuiltinDriverOptions = { "azureStorageBlob": AzureStorageBlobOptions; "azure-storage-table": AzureStorageTableOptions; "azureStorageTable": AzureStorageTableOptions; + "bun-redis": BunRedisOptions; + "bunRedis": BunRedisOptions; "capacitor-preferences": CapacitorPreferencesOptions; "capacitorPreferences": CapacitorPreferencesOptions; "cloudflare-kv-binding": CloudflareKVBindingOptions; @@ -98,6 +101,8 @@ export const builtinDrivers = { "azureStorageBlob": "unstorage/drivers/azure-storage-blob", "azure-storage-table": "unstorage/drivers/azure-storage-table", "azureStorageTable": "unstorage/drivers/azure-storage-table", + "bun-redis": "unstorage/drivers/bun-redis", + "bunRedis": "unstorage/drivers/bun-redis", "capacitor-preferences": "unstorage/drivers/capacitor-preferences", "capacitorPreferences": "unstorage/drivers/capacitor-preferences", "cloudflare-kv-binding": "unstorage/drivers/cloudflare-kv-binding", diff --git a/src/drivers/bun-redis.ts b/src/drivers/bun-redis.ts new file mode 100644 index 000000000..178a2f39f --- /dev/null +++ b/src/drivers/bun-redis.ts @@ -0,0 +1,129 @@ +/// + +import { type RedisOptions as _RedisOptions, RedisClient } from "bun"; +import { defineDriver, joinKeys } from "./utils/index.ts"; + +export interface RedisOptions extends _RedisOptions { + /** + * Optional prefix to use for all keys. Can be used for namespacing. + */ + base?: string; + + /** + * Url to use for connecting to redis. Takes precedence over `host` option. Has the format `redis://:@:` + */ + url?: string; + + /** + * Default TTL for all items in seconds. + */ + ttl?: number; + + /** + * How many keys to scan at once. + * + * [redis documentation](https://redis.io/docs/latest/commands/scan/#the-count-option) + */ + scanCount?: number; + + /** + * Whether to initialize the redis instance immediately. + * Otherwise, it will be initialized on the first read/write call. + * @default false + */ + preConnect?: boolean; +} + +const DRIVER_NAME = "bun-redis"; + +export default defineDriver((opts: RedisOptions) => { + let redisClient: RedisClient; + const getRedisClient = () => { + if (redisClient) { + return redisClient; + } + const { url, ...redisOpts } = opts; + if (url) { + redisClient = new RedisClient(url, redisOpts); + } else { + redisClient = new RedisClient(); + } + return redisClient; + }; + + const base = (opts.base || "").replace(/:$/, ""); + const p = (...keys: string[]) => joinKeys(base, ...keys); + const d = (key: string) => (base ? key.replace(`${base}:`, "") : key); + + if (opts.preConnect) { + try { + getRedisClient(); + } catch { + // Silent fail + } + } + + const scan = async (pattern: string): Promise => { + const client = getRedisClient(); + const keys: string[] = []; + let cursor = "0"; + do { + const [nextCursor, scanKeys] = opts.scanCount + ? await client.scan(cursor, "MATCH", pattern, "COUNT", opts.scanCount) + : await client.scan(cursor, "MATCH", pattern); + cursor = nextCursor; + keys.push(...scanKeys); + } while (cursor !== "0"); + return keys; + }; + + return { + name: DRIVER_NAME, + options: opts, + getInstance: getRedisClient, + async hasItem(key) { + const result = await getRedisClient().exists(p(key)); + return Boolean(result); + }, + async getItem(key) { + const value = await getRedisClient().get(p(key)); + return value ?? null; + }, + async getItems(items) { + const keys = items.map((item) => p(item.key)); + const data = await getRedisClient().mget(...keys); + + return keys.map((key, index) => { + return { + key: d(key), + value: data[index] ?? null, + }; + }); + }, + async setItem(key, value, tOptions) { + const ttl = tOptions?.ttl ?? opts.ttl; + if (ttl) { + await getRedisClient().set(p(key), value, "EX", String(ttl)); + } else { + await getRedisClient().set(p(key), value); + } + }, + async removeItem(key) { + await getRedisClient().del(p(key)); + }, + async getKeys(base) { + const keys = await scan(p(base, "*")); + return keys.map((key) => d(key)); + }, + async clear(base) { + const keys = await scan(p(base, "*")); + if (keys.length === 0) { + return; + } + await getRedisClient().del(...keys); + }, + dispose() { + return getRedisClient().close(); + }, + }; +}); diff --git a/test/drivers/bun-redis.test.ts b/test/drivers/bun-redis.test.ts new file mode 100644 index 000000000..114b3d0c6 --- /dev/null +++ b/test/drivers/bun-redis.test.ts @@ -0,0 +1,50 @@ +import { RedisClient } from "bun"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import bunRedisDriver from "../../src/drivers/bun-redis.ts"; +import { testDriver } from "./utils.ts"; + +describe("drivers: bun-redis", () => { + let testRedisClient: RedisClient; + + beforeAll(async () => { + testRedisClient = new RedisClient("redis://localhost:6379"); + try { + await testRedisClient.connect(); + } catch { + // Redis might not be available, tests will be skipped + } + }); + + afterAll(async () => { + testRedisClient?.close(); + }); + + const driver = bunRedisDriver({ + base: "test:", + url: "redis://localhost:6379", + }); + + testDriver({ + driver, + additionalTests(ctx) { + it("verify stored keys", async () => { + await ctx.storage.setItem("s1:a", "test_data"); + await ctx.storage.setItem("s2:a", "test_data"); + await ctx.storage.setItem("s3:a?q=1", "test_data"); + + const keys = await testRedisClient.keys("test:*"); + expect(keys).toMatchInlineSnapshot(` + [ + "test:s1:a", + "test:s2:a", + "test:s3:a", + ] + `); + }); + + it("exposes instance", () => { + expect(driver.getInstance?.()).toBeInstanceOf(RedisClient); + }); + }, + }); +});