T-HUB/apps/api/src/media-control/redis.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

177 lines
7.3 KiB
TypeScript

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<string | null>;
mget(keys: string[]): Promise<Array<string | null>>;
/** SET key value PX ttl [NX]; resolves false when NX lost. */
set(key: string, value: string, options?: { nx?: boolean; pxMs?: number }): Promise<boolean>;
incr(key: string): Promise<number>;
sadd(key: string, member: string): Promise<void>;
smembers(key: string): Promise<string[]>;
publish(channel: string, message: string): Promise<number>;
eval(script: string, keys: string[], args: string[]): Promise<RedisReply>;
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<RedisConnection> {
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<void>((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<RedisReply> {
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<RedisConnection> | 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<RedisReply> {
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<string | null> { return asString(await this.send(["GET", key])); }
async mget(keys: string[]): Promise<Array<string | null>> {
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<boolean> {
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<number> {
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<void> { await this.send(["SADD", key, member]); }
async smembers(key: string): Promise<string[]> {
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<number> {
const reply = await this.send(["PUBLISH", channel, message]);
return typeof reply === "number" ? reply : 0;
}
async eval(script: string, keys: string[], args: string[]): Promise<RedisReply> {
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);
}
}