import net, { type Socket } from "node:net"; import tls from "node:tls"; /** Minimal Redis surface the control plane needs; faked in unit tests. */ export interface ControlRedis { get(key: string): Promise; mget(keys: string[]): Promise>; /** SET key value PX ttl [NX]; resolves false when NX lost. */ set(key: string, value: string, options?: { nx?: boolean; pxMs?: number }): Promise; incr(key: string): Promise; sadd(key: string, member: string): Promise; smembers(key: string): Promise; publish(channel: string, message: string): Promise; eval(script: string, keys: string[], args: string[]): Promise; close(): void; } export type RedisReply = string | number | null | RedisReply[] | RedisError; export class RedisError extends Error {} const MAX_BULK = 4_000_000; const MAX_ARRAY = 100_000; export function encode(parts: string[]): Buffer { return Buffer.from(`*${parts.length}\r\n${parts.map((part) => `$${Buffer.byteLength(part)}\r\n${part}\r\n`).join("")}`); } export function parse(buffer: Buffer, offset = 0): { reply: RedisReply; end: number } | null { if (offset >= buffer.length) return null; const endLine = buffer.indexOf("\r\n", offset); if (endLine < 0) return null; const type = String.fromCharCode(buffer[offset] ?? 0); const line = buffer.subarray(offset + 1, endLine).toString(); let pos = endLine + 2; if (type === "+") return { reply: line, end: pos }; if (type === "-") return { reply: new RedisError(line), end: pos }; if (type === ":") return { reply: Number(line), end: pos }; const length = Number(line); if (type === "$") { if (length === -1) return { reply: null, end: pos }; if (!Number.isInteger(length) || length < 0 || length > MAX_BULK) throw new Error("invalid Redis bulk length"); if (buffer.length < pos + length + 2) return null; return { reply: buffer.subarray(pos, pos + length).toString(), end: pos + length + 2 }; } if (type === "*") { if (length === -1) return { reply: null, end: pos }; if (!Number.isInteger(length) || length < 0 || length > MAX_ARRAY) throw new Error("invalid Redis array length"); const replies: RedisReply[] = []; for (let i = 0; i < length; i += 1) { const item = parse(buffer, pos); if (!item) return null; replies.push(item.reply); pos = item.end; } return { reply: replies, end: pos }; } throw new Error("invalid Redis reply"); } class RedisConnection { private buffer = Buffer.alloc(0); private readonly pending: Array<{ resolve: (reply: RedisReply) => void; reject: (error: Error) => void }> = []; closed = false; private constructor(private readonly socket: Socket) { socket.on("data", (chunk: Buffer) => { this.buffer = Buffer.concat([this.buffer, chunk]); if (this.buffer.length > MAX_BULK * 2) { socket.destroy(new Error("Redis reply too large")); return; } try { for (let parsed = parse(this.buffer); parsed; parsed = parse(this.buffer)) { this.buffer = this.buffer.subarray(parsed.end); const waiter = this.pending.shift(); if (parsed.reply instanceof RedisError) waiter?.reject(parsed.reply); else waiter?.resolve(parsed.reply); } } catch (error: unknown) { socket.destroy(error instanceof Error ? error : new Error("Redis protocol error")); } }); const fail = (error: Error): void => { this.closed = true; for (const item of this.pending.splice(0)) item.reject(error); }; socket.on("error", fail); socket.on("close", () => fail(new Error("Redis disconnected"))); } static async connect(url: URL): Promise { const port = Number(url.port || 6379); const socket = url.protocol === "rediss:" ? tls.connect({ host: url.hostname, port, servername: url.hostname, rejectUnauthorized: true }) : net.connect({ host: url.hostname, port }); await new Promise((resolve, reject) => { socket.once(url.protocol === "rediss:" ? "secureConnect" : "connect", () => resolve()); socket.once("error", reject); }); const connection = new RedisConnection(socket); if (url.password) await connection.send(["AUTH", decodeURIComponent(url.username || "default"), decodeURIComponent(url.password)]); const db = url.pathname.replace(/^\//, ""); if (db !== "") await connection.send(["SELECT", db]); return connection; } send(parts: string[]): Promise { if (this.closed) return Promise.reject(new Error("Redis disconnected")); return new Promise((resolve, reject) => { this.pending.push({ resolve, reject }); this.socket.write(encode(parts)); }); } close(): void { this.closed = true; this.socket.destroy(); } } function asString(reply: RedisReply): string | null { return typeof reply === "string" ? reply : null; } /** Lazily (re)connecting client: a dropped socket is replaced on the next call. */ export class RespControlRedis implements ControlRedis { private connection: Promise | null = null; private readonly url: URL; constructor(rawUrl: string) { this.url = new URL(rawUrl); if (this.url.protocol !== "redis:" && this.url.protocol !== "rediss:") throw new Error("invalid Redis URL"); } private async send(parts: string[]): Promise { let current = this.connection; if (current) { const conn = await current.catch(() => null); if (!conn || conn.closed) current = null; } if (!current) { current = RedisConnection.connect(this.url); this.connection = current; current.catch(() => { if (this.connection === current) this.connection = null; }); } return (await current).send(parts); } async get(key: string): Promise { return asString(await this.send(["GET", key])); } async mget(keys: string[]): Promise> { if (keys.length === 0) return []; const reply = await this.send(["MGET", ...keys]); return Array.isArray(reply) ? reply.map(asString) : keys.map(() => null); } async set(key: string, value: string, options: { nx?: boolean; pxMs?: number } = {}): Promise { const parts = ["SET", key, value]; if (options.pxMs !== undefined) parts.push("PX", String(Math.max(1, Math.ceil(options.pxMs)))); if (options.nx) parts.push("NX"); return (await this.send(parts)) === "OK"; } async incr(key: string): Promise { const reply = await this.send(["INCR", key]); if (typeof reply !== "number") throw new Error("unexpected INCR reply"); return reply; } async sadd(key: string, member: string): Promise { await this.send(["SADD", key, member]); } async smembers(key: string): Promise { const reply = await this.send(["SMEMBERS", key]); return Array.isArray(reply) ? reply.filter((item): item is string => typeof item === "string") : []; } async publish(channel: string, message: string): Promise { const reply = await this.send(["PUBLISH", channel, message]); return typeof reply === "number" ? reply : 0; } async eval(script: string, keys: string[], args: string[]): Promise { return this.send(["EVAL", script, String(keys.length), ...keys, ...args]); } close(): void { const current = this.connection; this.connection = null; void current?.then((conn) => conn.close(), () => undefined); } }