alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
291 lines (246 loc) • 7.88 kB
text/typescript
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;
}
}