UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

291 lines (246 loc) 7.88 kB
import { $env, $hook, $inject, Alepha, AlephaError, type Static, z, } from "alepha"; import { $logger } from "alepha/logger"; import { RedisProvider, type RedisSetOptions } from "./RedisProvider.ts"; const envSchema = z.object({ REDIS_URL: z.text({ default: "redis://localhost:6379", description: "Redis connection URL", }), }); declare module "alepha" { interface Env extends Partial<Static<typeof envSchema>> {} } /** * Bun Redis client provider using Bun's native Redis client. * * This provider uses Bun's built-in `RedisClient` class for Redis connections, * which provides excellent performance (7.9x faster than ioredis) on the Bun runtime. * * @example * ```ts * // Set REDIS_URL environment variable (default: redis://localhost:6379) * // REDIS_URL=redis://:password@myredis.example.com:6379 * * // Or configure programmatically * alepha.with({ * provide: RedisProvider, * use: BunRedisProvider, * }); * ``` */ export class BunRedisProvider extends RedisProvider { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly env = $env(envSchema); protected client?: Bun.RedisClient; public get publisher(): Bun.RedisClient { if (!this.client?.connected) { throw new AlephaError("Redis client is not ready"); } return this.client; } public override get isReady(): boolean { return this.client?.connected ?? false; } protected readonly start = $hook({ on: "start", handler: () => this.connect(), }); protected readonly stop = $hook({ on: "stop", handler: () => this.close(), }); /** * Connect to the Redis server. */ public override async connect(): Promise<void> { // Check if we're running in Bun if (!this.alepha.isBun()) { throw new AlephaError( "BunRedisProvider requires the Bun runtime. Use NodeRedisProvider for Node.js.", ); } this.log.debug("Connecting..."); this.client = new Bun.RedisClient(this.getUrl(), { autoReconnect: true, enableAutoPipelining: true, }); this.client.onconnect = () => { this.log.trace("Redis connected"); }; this.client.onclose = (error) => { if (this.alepha.isStarted() && error) { this.log.error("Redis connection closed", error); } }; await this.client.connect(); this.log.info("Connection OK"); } /** * Close the connection to the Redis server. */ public override async close(): Promise<void> { if (this.client) { this.log.debug("Closing connection..."); this.client.close(); this.client = undefined; this.log.info("Connection closed"); } } /** * Create a duplicate connection for pub/sub or other isolated operations. */ public async duplicate(): Promise<Bun.RedisClient> { if (typeof Bun === "undefined") { throw new AlephaError("BunRedisProvider requires the Bun runtime."); } const client = new Bun.RedisClient(this.getUrl(), { autoReconnect: true, enableAutoPipelining: true, }); client.onclose = (error) => { if (this.alepha.isStarted() && error) { this.log.error("Redis duplicate connection closed", error); } }; await client.connect(); return client; } public override async get(key: string): Promise<Buffer | undefined> { this.log.trace(`Getting key ${key}`); const resp = await this.publisher.getBuffer(key); if (resp === null) { return undefined; } return Buffer.from(resp); } public override async set( key: string, value: Buffer | string, options?: RedisSetOptions, ): Promise<Buffer> { const buf = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf-8"); // Build SET command arguments const args: string[] = [key, buf.toString("binary")]; // Handle expiration object format (from alepha/cache-redis, alepha/lock-redis) if (options?.expiration) { if (options.expiration.type === "KEEPTTL") { args.push("KEEPTTL"); } else { args.push(options.expiration.type, String(options.expiration.value)); } } // Handle direct expiration properties if (options?.EX !== undefined) { args.push("EX", String(options.EX)); } if (options?.PX !== undefined) { args.push("PX", String(options.PX)); } if (options?.EXAT !== undefined) { args.push("EXAT", String(options.EXAT)); } if (options?.PXAT !== undefined) { args.push("PXAT", String(options.PXAT)); } if (options?.KEEPTTL) { args.push("KEEPTTL"); } // Handle condition object format if (options?.condition === "NX") { args.push("NX"); } else if (options?.condition === "XX") { args.push("XX"); } // Handle direct condition properties if (options?.NX) { args.push("NX"); } if (options?.XX) { args.push("XX"); } if (options?.GET) { args.push("GET"); } // Capture the reply. With the `GET` option the reply carries the *prior* // value, and the entire `$lock` protocol (`SET … NX GET`) depends on reading // it. Previously this discarded the reply and always returned the value it // just wrote, so under Bun every lock contender saw its own id back and // believed it had acquired the lock — RedisLockProvider (and therefore every // scheduler dedup / migration guard) became a silent no-op across instances. const resp = args.length === 2 ? await this.publisher.set(key, buf) : await this.publisher.send("SET", args); // Mirror NodeRedisProvider: "OK"/nil means "applied, no prior value" → echo // the value we wrote; any other reply is the prior value returned by GET. if (resp == null || resp === "OK") { return buf; } return Buffer.isBuffer(resp) ? resp : Buffer.from(String(resp), "binary"); } public override async has(key: string): Promise<boolean> { return this.publisher.exists(key); } public override async keys(pattern: string): Promise<string[]> { const keys = await this.publisher.send("KEYS", [pattern]); if (!Array.isArray(keys)) { return []; } return keys.map((key) => key instanceof Uint8Array ? Buffer.from(key).toString() : String(key), ); } public override async del(keys: string[]): Promise<void> { if (keys.length === 0) { return; } await this.publisher.send("DEL", keys); } // --------------------------------------------------------- // Queue operations // --------------------------------------------------------- public override async lpush(key: string, value: string): Promise<void> { await this.publisher.send("LPUSH", [key, value]); } public override async rpop(key: string): Promise<string | undefined> { const value = await this.publisher.send("RPOP", [key]); if (value == null) { return undefined; } if (value instanceof Uint8Array) { return Buffer.from(value).toString(); } return String(value); } // --------------------------------------------------------- // Pub/Sub operations // --------------------------------------------------------- public override async publish( channel: string, message: string, ): Promise<void> { await this.publisher.publish(channel, message); } // --------------------------------------------------------- // Counter operations // --------------------------------------------------------- public override async incr(key: string, amount: number): Promise<number> { const result = await this.publisher.send("INCRBY", [key, String(amount)]); return Number(result); } /** * Get the Redis connection URL. */ protected getUrl(): string { return this.env.REDIS_URL; } }