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/.
177 lines
7.3 KiB
TypeScript
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);
|
|
}
|
|
}
|