// SPDX-License-Identifier: AGPL-3.0-or-later import type {IKVSubscription} from '@pkgs/kv_client/src/IKVProvider'; import type {IKVLogger, KVClientMode, KVClusterNode} from '@pkgs/kv_client/src/KVClientConfig'; import Redis from 'ioredis'; interface KVSubscriptionConfig { url: string; mode?: KVClientMode; clusterNodes?: Array; timeoutMs: number; logger: IKVLogger; } export class KVSubscription implements IKVSubscription { private readonly url: string; private readonly mode: KVClientMode; private readonly clusterNodes: Array; private readonly timeoutMs: number; private readonly logger: IKVLogger; private readonly channels: Set = new Set(); private readonly messageCallbacks: Set<(channel: string, message: string) => void> = new Set(); private readonly errorCallbacks: Set<(error: Error) => void> = new Set(); private client: Redis | null = null; constructor(config: KVSubscriptionConfig) { this.url = config.url; this.mode = config.mode ?? 'standalone'; this.clusterNodes = config.clusterNodes ?? []; this.timeoutMs = config.timeoutMs; this.logger = config.logger; } async connect(): Promise { if (this.client !== null) { return; } const connectionUrl = this.resolveSubscriptionUrl(); const client = new Redis(connectionUrl, { autoResubscribe: true, connectTimeout: this.timeoutMs, commandTimeout: this.timeoutMs, maxRetriesPerRequest: 1, retryStrategy: createRetryStrategy(), }); client.on('message', (channel: string, message: string) => { for (const callback of this.messageCallbacks) { callback(channel, message); } }); client.on('error', (error: Error) => { this.logger.error({error}, 'KV subscription error'); for (const callback of this.errorCallbacks) { callback(error); } }); this.client = client; if (this.channels.size > 0) { await this.client.subscribe(...Array.from(this.channels)); } } on(event: 'message', callback: (channel: string, message: string) => void): void; on(event: 'error', callback: (error: Error) => void): void; on( event: 'message' | 'error', callback: ((channel: string, message: string) => void) | ((error: Error) => void), ): void { if (event === 'message') { this.messageCallbacks.add(callback as (channel: string, message: string) => void); return; } this.errorCallbacks.add(callback as (error: Error) => void); } off(event: 'message', callback: (channel: string, message: string) => void): void; off(event: 'error', callback: (error: Error) => void): void; off( event: 'message' | 'error', callback: ((channel: string, message: string) => void) | ((error: Error) => void), ): void { if (event === 'message') { this.messageCallbacks.delete(callback as (channel: string, message: string) => void); return; } this.errorCallbacks.delete(callback as (error: Error) => void); } async subscribe(...channels: Array): Promise { const newChannels = channels.filter((channel) => { if (this.channels.has(channel)) { return false; } this.channels.add(channel); return true; }); if (newChannels.length === 0 || this.client === null) { return; } await this.client.subscribe(...newChannels); } async unsubscribe(...channels: Array): Promise { const removedChannels = channels.filter((channel) => this.channels.delete(channel)); if (removedChannels.length === 0 || this.client === null) { return; } await this.client.unsubscribe(...removedChannels); } async quit(): Promise { const client = this.client; this.client = null; if (client === null) { return; } await client.quit(); } async disconnect(): Promise { const client = this.client; this.client = null; if (client === null) { return; } client.disconnect(false); } removeAllListeners(event?: 'message' | 'error'): void { if (!event || event === 'message') { this.messageCallbacks.clear(); } if (!event || event === 'error') { this.errorCallbacks.clear(); } } private resolveSubscriptionUrl(): string { if (this.mode !== 'cluster' || this.clusterNodes.length === 0) { return this.url; } const node = this.clusterNodes[0]; return `redis://${node.host}:${node.port}`; } } function createRetryStrategy(): (times: number) => number { return (times: number) => { const backoffMs = Math.min(times * 100, 2000); return backoffMs; }; }