import type { TenantId } from "@suaveplan/api-types/tenant"; import type { BrokerEnvelope, BrokerHandler, BrokerSubscription, WsBroker, } from "@suaveplan/api-ws/broker"; import { WS_BROKER_REDIS_CODES, WsBrokerRedisError, } from "../error-codes/error-codes.js"; import { type BufferedEvent, readBufferedEvents, writeBufferedEvent, } from "../event-buffer/event-buffer.js"; import { type BrokerHealthSnapshot, evaluateBrokerHealth, } from "../health/health.js"; import { connectionLostCounter, publishDurationHistogram, publishesCounter, } from "../instruments/instruments.js"; import { type RedisWsBrokerOptions, type ResolvedRedisWsBrokerOptions, redisWsBrokerOptionsSchema, resolveRedisWsBrokerOptions, } from "../options/options.js"; import { encodeEnvelope } from "../publish/publish.js"; import { createRespClient, type RedisPubSubLike, } from "../resp-client/resp-client.js"; import { channelFromTopic, dispatchInboundFrame, LocalSubscriberRegistry, topicFromChannel, } from "../subscribe/subscribe.js"; /** * Concrete shape returned by {@link createRedisWsBroker}. Extends the * abstract `WsBroker` contract with broker-specific introspection used * by the host's readiness endpoint and by tests. */ export interface RedisWsBroker extends WsBroker { publish(topic: string, envelope: BrokerEnvelope): Promise; subscribe( topic: string, handler: BrokerHandler ): Promise; dispose(): Promise; /** Append-to-buffer surface consumed by `api-ws-bun` + `api-sse-bun`. */ writeBuffer( tenant: TenantId, topic: string, event: BufferedEvent ): Promise; /** Replay-from-buffer surface consumed by `api-ws-bun` + `api-sse-bun`. */ readBuffer( tenant: TenantId, topic: string, sinceEventId: number ): Promise; /** Health probe surface consumed by `host.readiness()`. */ healthz(): BrokerHealthSnapshot; /** Disposed flag — set after `dispose()` resolves. */ isDisposed(): boolean; /** Resolved (defaulted) options the broker is using. */ readonly options: ResolvedRedisWsBrokerOptions; } /** Factory contract for swapping in an alternative client (e.g. ioredis adapter). */ export type RedisClientFactory = ( url: string, role: "publisher" | "subscriber" ) => RedisPubSubLike; /** Extended options accepted by {@link createRedisWsBroker} (vs. the wire schema). */ export interface CreateRedisWsBrokerOptions extends RedisWsBrokerOptions { /** Injectable client factory; defaults to the built-in RESP client. */ readonly clientFactory?: RedisClientFactory; /** Wait-for-ready timeout when constructing the broker. Default 5_000 ms. */ readonly readyTimeoutMs?: number; } const DEFAULT_READY_TIMEOUT_MS = 5_000; function waitForReady( client: RedisPubSubLike, timeoutMs: number ): Promise { if (client.isReady) return Promise.resolve(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { off(); reject( new WsBrokerRedisError( WS_BROKER_REDIS_CODES.CONNECTION_LOST, `Redis client did not become ready within ${timeoutMs}ms` ) ); }, timeoutMs); const off = client.on((evt) => { if (evt.event === "ready") { clearTimeout(timer); off(); resolve(); } else if (evt.event === "error" || evt.event === "close") { clearTimeout(timer); off(); reject( new WsBrokerRedisError( WS_BROKER_REDIS_CODES.CONNECTION_LOST, `Redis connect failed: ${evt.event === "error" ? evt.error.message : "closed"}` ) ); } }); }); } /** * Build a Redis-backed `WsBroker`. Two RESP clients are opened against the * supplied URL — one for replies (publish/buffer ops) and one held in * subscribe-mode for the inbound pub/sub stream. Pattern-subscription is * established eagerly at construction time so the first publish is visible * to peers without further setup. * * Lifecycle: * * 1. `createRedisWsBroker(opts)` parses + resolves options, opens both * clients, awaits ready on each, then `PSUBSCRIBE`s on * `*`. * 2. `broker.broadcast` and `broker.publish` (alias) encode the envelope * and `PUBLISH` to ``. * 3. The subscribe loop decodes each frame and routes via * {@link LocalSubscriberRegistry}. * 4. `broker.dispose()` `PUNSUBSCRIBE`s, quits both clients, and refuses * subsequent calls. */ export async function createRedisWsBroker( options: CreateRedisWsBrokerOptions ): Promise> { // Strip broker-factory-only fields before schema validation; the schema // describes the wire shape (RedisWsBrokerOptions), not the extra // factory-only knobs (clientFactory, readyTimeoutMs). const { clientFactory: _cf, readyTimeoutMs: _rt, ...schemaInput } = options; const parsed = redisWsBrokerOptionsSchema.safeParse(schemaInput); if (!parsed.success) { throw new WsBrokerRedisError( WS_BROKER_REDIS_CODES.CONFIG_INVALID, `createRedisWsBroker received invalid options: ${parsed.error.message}` ); } const resolved = resolveRedisWsBrokerOptions( parsed.data as RedisWsBrokerOptions ); const factory: RedisClientFactory = options.clientFactory ?? ((url, role) => createRespClient({ url, commandTimeoutMs: resolved.commandTimeoutMs, isSubscriber: role === "subscriber", })); const publisher = factory(resolved.connection, "publisher"); const subscriber = factory(resolved.connection, "subscriber"); const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; await Promise.all([ waitForReady(publisher, readyTimeout), waitForReady(subscriber, readyTimeout), ]); const registry = new LocalSubscriberRegistry(); let disposed = false; let lastError: string | undefined; const pattern = `${resolved.channelPrefix}*`; const detachSub = subscriber.on((evt) => { if (evt.event === "message") { const topic = topicFromChannel(evt.channel, resolved.channelPrefix); if (topic === null) return; void dispatchInboundFrame(registry, topic, evt.payload, (err) => { lastError = err.message; }); } else if (evt.event === "close" || evt.event === "error") { connectionLostCounter().add(1); if (evt.event === "error") lastError = evt.error.message; } }); const detachPub = publisher.on((evt) => { if (evt.event === "close" || evt.event === "error") { connectionLostCounter().add(1); if (evt.event === "error") lastError = evt.error.message; } }); try { await subscriber.psubscribe(pattern); } catch (err) { await publisher.quit().catch(() => undefined); await subscriber.quit().catch(() => undefined); throw new WsBrokerRedisError( WS_BROKER_REDIS_CODES.SUBSCRIBE_FAILED, `PSUBSCRIBE failed: ${(err as Error).message}`, { cause: err } ); } function assertLive(operation: string): void { if (disposed) { throw new WsBrokerRedisError( WS_BROKER_REDIS_CODES.CONNECTION_LOST, `cannot ${operation}: broker has been disposed` ); } } async function publishImpl( topic: string, envelope: BrokerEnvelope ): Promise { assertLive("publish"); const start = Date.now(); const stamped: BrokerEnvelope = { ...envelope, originHost: envelope.originHost ?? resolved.hostId, }; let frame: ReturnType>; try { frame = encodeEnvelope(stamped, { compressionThresholdBytes: resolved.compressionThresholdBytes, disableCompression: resolved.disableCompression, }); } catch (err) { publishesCounter().add(1, { topic, outcome: "error" }); throw err; } const channel = channelFromTopic(topic, resolved.channelPrefix); try { await publisher.publish(channel, frame.bytes); publishesCounter().add(1, { topic, outcome: "ok" }); publishDurationHistogram().record(Date.now() - start); } catch (err) { publishesCounter().add(1, { topic, outcome: "error" }); throw new WsBrokerRedisError( WS_BROKER_REDIS_CODES.PUBLISH_FAILED, `PUBLISH failed for topic=${topic}: ${(err as Error).message}`, { cause: err } ); } } const broker: RedisWsBroker = { options: resolved, async publish(topic, envelope): Promise { await publishImpl(topic, envelope); }, async subscribe( topic: string, handler: BrokerHandler ): Promise { assertLive("subscribe"); registry.add(topic, handler); let active = true; return { topic, unsubscribe(): void { if (!active) return; active = false; registry.remove(topic, handler); }, }; }, async dispose(): Promise { if (disposed) return; disposed = true; registry.clear(); detachSub(); detachPub(); try { await subscriber.punsubscribe(pattern); } catch { /* socket may have already closed; safe to ignore */ } await Promise.all([ publisher.quit().catch(() => undefined), subscriber.quit().catch(() => undefined), ]); }, async writeBuffer( tenant: TenantId, topic: string, event: BufferedEvent ): Promise { assertLive("writeBuffer"); await writeBufferedEvent(publisher, tenant, topic, event, { keyPrefix: resolved.keyPrefix, maxEvents: resolved.eventBufferMaxEvents, ttlSec: resolved.eventBufferTtlSec, }); }, async readBuffer( tenant: TenantId, topic: string, sinceEventId: number ): Promise { assertLive("readBuffer"); return readBufferedEvents(publisher, tenant, topic, sinceEventId, { keyPrefix: resolved.keyPrefix, maxEvents: resolved.eventBufferMaxEvents, ttlSec: resolved.eventBufferTtlSec, }); }, healthz(): BrokerHealthSnapshot { return evaluateBrokerHealth(publisher, subscriber, lastError); }, isDisposed(): boolean { return disposed; }, }; return broker; }