265 lines
8.3 KiB
TypeScript
265 lines
8.3 KiB
TypeScript
/**
|
|
* 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<RedisDriverOptions>,
|
|
onReply?: (value: RespValue) => void,
|
|
onReconnect?: () => void,
|
|
): RedisConn {
|
|
let socket: { write(data: Uint8Array): void; end(): void } | null = null;
|
|
const pending: Uint8Array[] = [];
|
|
let buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
let closed = false;
|
|
let connecting = false;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | 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<unknown> } }).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<string, Set<Handler>>();
|
|
const redisSubscribed = new Set<string>();
|
|
|
|
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<string, string | undefined> } }).process?.env?.[
|
|
key
|
|
];
|
|
}
|