/** * Redis-backed pub/sub driver — lets pub/sub messages cross **processes**, so * multiple WrNexus app runs (or apps on different domains) can communicate. Uses a * self-contained RESP client over a raw TCP socket (`Bun.connect`), so it adds no * npm dependency. It also backs @wrnexus/core's realtime bridge for horizontal * scaling. * * import { createPubSub } from "@wrnexus/pubsub"; * import { redisDriver } from "@wrnexus/pubsub/redis"; * * const bus = createPubSub(redisDriver("redis://localhost:6379")); * bus.subscribe("order:*", (msg, topic) => { ... }); // any app process receives it * await bus.publish("order:created", { id: 7 }); * * Pattern mapping: an exact topic uses Redis SUBSCRIBE; a wildcard pattern * (`ns:*` or `*`) uses PSUBSCRIBE, whose glob semantics match this library's. */ import type { Handler, PubSubDriver } from "./index.ts"; import { concat, encodeCommand, parseReply, type RespValue } from "./resp.ts"; interface RedisConn { send(bytes: Uint8Array): void; close(): void; } interface ParsedUrl { host: string; port: number; password?: string; db?: number; tls: boolean; } export interface RedisDriverOptions { /** Maximum writes buffered while Redis is unavailable. Default 1000. */ maxPending?: number; /** Initial reconnect delay. Doubles up to reconnectMaxDelayMs. Default 100. */ reconnectDelayMs?: number; /** Maximum reconnect delay. Default 5000. */ reconnectMaxDelayMs?: number; } function parseUrl(url: string): ParsedUrl { const u = new URL(url); if (u.protocol !== "redis:" && u.protocol !== "rediss:") { throw new TypeError("Redis URL must use redis:// or rediss://"); } const path = u.pathname.replace(/^\//, ""); const port = u.port ? Number(u.port) : 6379; const db = path ? Number(path) : undefined; if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new TypeError("Redis URL has an invalid port"); if (db !== undefined && (!Number.isInteger(db) || db < 0)) throw new TypeError("Redis URL database must be a non-negative integer"); return { host: u.hostname || "localhost", port, password: u.password ? decodeURIComponent(u.password) : undefined, db, tls: u.protocol === "rediss:", }; } const isPattern = (p: string): boolean => p.includes("*"); /** * Open a Redis TCP connection. `onReply` receives every parsed top-level reply * (used by the subscriber connection to dispatch message/pmessage pushes). */ function connect( cfg: ParsedUrl, options: Required, onReply?: (value: RespValue) => void, onReconnect?: () => void, ): RedisConn { let socket: { write(data: Uint8Array): void; end(): void } | null = null; const pending: Uint8Array[] = []; let buf: Uint8Array = new Uint8Array(0); let closed = false; let connecting = false; let reconnectTimer: ReturnType | null = null; let reconnectAttempt = 0; let openedOnce = false; // Bun.connect is available in the Bun runtime. const Bun = (globalThis as { Bun?: { connect: (opts: unknown) => Promise } }).Bun; if (!Bun?.connect) throw new Error("redisDriver requires the Bun runtime (Bun.connect)."); const scheduleReconnect = () => { if (closed || reconnectTimer) return; const delay = Math.min( options.reconnectMaxDelayMs, options.reconnectDelayMs * 2 ** reconnectAttempt++, ); reconnectTimer = setTimeout(() => { reconnectTimer = null; start(); }, delay); }; const start = () => { if (closed || connecting) return; connecting = true; void Bun.connect({ hostname: cfg.host, port: cfg.port, tls: cfg.tls, socket: { open(s: { write(data: Uint8Array): void; end(): void }) { connecting = false; socket = s; reconnectAttempt = 0; if (cfg.password) s.write(encodeCommand(["AUTH", cfg.password])); if (cfg.db) s.write(encodeCommand(["SELECT", String(cfg.db)])); if (openedOnce) onReconnect?.(); openedOnce = true; for (const p of pending) s.write(p); pending.length = 0; }, data(_s: unknown, chunk: Uint8Array) { buf = buf.length ? concat([buf, chunk]) : chunk; for (;;) { const r = parseReply(buf); if (!r) break; buf = buf.slice(r.next); onReply?.(r.value); } }, error() { socket = null; connecting = false; scheduleReconnect(); }, close() { socket = null; connecting = false; scheduleReconnect(); }, }, }).catch(() => { connecting = false; scheduleReconnect(); }); }; start(); return { send(bytes) { if (socket) socket.write(bytes); else { if (closed) throw new Error("Redis pubsub connection is closed"); if (pending.length >= options.maxPending) { throw new Error("Redis connection is unavailable and its pending write queue is full"); } pending.push(bytes); } }, close() { closed = true; if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = null; pending.length = 0; socket?.end(); socket = null; }, }; } /** * A cross-process pub/sub driver backed by Redis. `url` defaults to * `$REDIS_URL` or `redis://localhost:6379`. */ export function redisDriver( url?: string, options: RedisDriverOptions = {}, ): PubSubDriver & { close(): void } { const cfg = parseUrl(url ?? getEnv("REDIS_URL") ?? "redis://localhost:6379"); const resolved = { maxPending: positiveInteger(options.maxPending ?? 1000, "Redis maxPending"), reconnectDelayMs: positiveInteger(options.reconnectDelayMs ?? 100, "Redis reconnectDelayMs"), reconnectMaxDelayMs: positiveInteger( options.reconnectMaxDelayMs ?? 5000, "Redis reconnectMaxDelayMs", ), }; if (resolved.reconnectMaxDelayMs < resolved.reconnectDelayMs) { throw new RangeError("Redis reconnectMaxDelayMs must be >= reconnectDelayMs"); } const subs = new Map>(); const redisSubscribed = new Set(); const dispatch = (key: string, topic: string, payload: string) => { const handlers = subs.get(key); if (!handlers) return; let message: unknown = payload; try { message = JSON.parse(payload); } catch { /* not JSON — deliver the raw string */ } for (const handler of handlers) void handler(message, topic); }; const subConn = connect( cfg, resolved, (value) => { if (!Array.isArray(value)) return; const kind = value[0]; if (kind === "message") dispatch(String(value[1]), String(value[1]), String(value[2])); else if (kind === "pmessage") dispatch(String(value[1]), String(value[2]), String(value[3])); }, () => { for (const pattern of redisSubscribed) { subConn.send(encodeCommand([isPattern(pattern) ? "PSUBSCRIBE" : "SUBSCRIBE", pattern])); } }, ); const pubConn = connect(cfg, resolved); return { publish(topic, message) { const payload = typeof message === "string" ? message : JSON.stringify(message); pubConn.send(encodeCommand(["PUBLISH", topic, payload])); }, subscribe(pattern, handler) { let set = subs.get(pattern); if (!set) subs.set(pattern, (set = new Set())); set.add(handler); if (!redisSubscribed.has(pattern)) { redisSubscribed.add(pattern); subConn.send(encodeCommand([isPattern(pattern) ? "PSUBSCRIBE" : "SUBSCRIBE", pattern])); } return () => { set!.delete(handler); if (!set!.size) { subs.delete(pattern); redisSubscribed.delete(pattern); subConn.send( encodeCommand([isPattern(pattern) ? "PUNSUBSCRIBE" : "UNSUBSCRIBE", pattern]), ); } }; }, close() { subConn.close(); pubConn.close(); }, }; } function positiveInteger(value: number, label: string): number { if (!Number.isInteger(value) || value < 1) { throw new RangeError(`${label} must be a positive integer`); } return value; } function getEnv(key: string): string | undefined { return (globalThis as { process?: { env?: Record } }).process?.env?.[ key ]; }