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/.
101 lines
5.5 KiB
TypeScript
101 lines
5.5 KiB
TypeScript
import * as http from "node:http";
|
|
import { readFileSync } from "node:fs";
|
|
import { createHash, createPublicKey } from "node:crypto";
|
|
import { resolve } from "node:path";
|
|
import pino from "pino";
|
|
import { DurableFence } from "./bus/fence";
|
|
import { RedisBus } from "./bus/redis";
|
|
import { FfmpegRelayJob, type EgressRelayFactory, type VaultHandleBroker } from "./relay/ffmpeg";
|
|
import type { EndpointValidator } from "./relay/endpoints";
|
|
import { MediaSupervisor, type RelayJob } from "./supervisor";
|
|
import { ProductionJob, type AssetStore, type ProductionEgress } from "./compositor/production-job";
|
|
import { resolveHelperBinary } from "./gate/binary";
|
|
import { isDesiredChannel, type DesiredChannel } from "./types";
|
|
|
|
const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
|
|
const port = Number(process.env.PORT ?? 3100);
|
|
const workerId = process.env.MEDIA_WORKER_ID ?? "media-worker-dev";
|
|
let supervisor: MediaSupervisor | null = null;
|
|
let bus: RedisBus | null = null;
|
|
let ready = false;
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if (req.url === "/healthz") {
|
|
res.writeHead(ready ? 200 : 503, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ status: ready ? "alive" : "starting", worker: workerId, channels: supervisor?.status() ?? [] }));
|
|
return;
|
|
}
|
|
res.writeHead(404).end();
|
|
});
|
|
|
|
function readDesired(): DesiredChannel[] {
|
|
const path = process.env.MEDIA_DESIRED_CONFIG;
|
|
if (!path) return [];
|
|
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
if (!Array.isArray(value) || !value.every(isDesiredChannel)) throw new Error("invalid desired channel config");
|
|
return value;
|
|
}
|
|
|
|
/** Deliberately fails closed until WP7 injects an egress relay and vault broker. */
|
|
const unavailableEgress: EgressRelayFactory = { async prepare() { throw new Error("egress relay is not configured"); } };
|
|
const unavailableValidator: EndpointValidator = { async validate() { throw new Error("endpoint validator is not configured"); } };
|
|
const unavailableVault: VaultHandleBroker = { async deliverPushTarget() { throw new Error("vault broker is not configured"); } };
|
|
const unavailableProductionEgress: ProductionEgress = { async prepare() { throw new Error("production egress is not configured"); } };
|
|
const unavailableAssets: AssetStore = { async localPath() { throw new Error("overlay asset store is not configured"); } };
|
|
const redactor = { redact: (_chunk: string) => "[media stderr redacted until a job secret-aware redactor is injected]" };
|
|
|
|
/** Per-channel scratch for gate FIFOs; the name is a hash so channel ids never reach a path. */
|
|
function scratchFor(channel: DesiredChannel): string {
|
|
const id = createHash("sha256").update(`${channel.workspaceId}:${channel.channelId}`).digest("hex").slice(0, 32);
|
|
return resolve(process.env.MEDIA_SCRATCH_DIR ?? ".media-worker-state/scratch", id);
|
|
}
|
|
|
|
/** v2 (production) snapshots run the compositor chain; v1 snapshots keep the single relay. */
|
|
function createRelay(channel: DesiredChannel, onExit: (reason: string) => void): RelayJob {
|
|
const onStderr = (message: string): void => logger.warn({ message }, "ffmpeg stderr");
|
|
if (channel.production) {
|
|
return new ProductionJob({
|
|
validator: unavailableValidator, egress: unavailableProductionEgress, vault: unavailableVault, assets: unavailableAssets,
|
|
redactor, onStderr, onExit, scratchDir: scratchFor(channel),
|
|
});
|
|
}
|
|
return new FfmpegRelayJob(unavailableValidator, unavailableEgress, unavailableVault, redactor, onStderr, onExit);
|
|
}
|
|
|
|
/** Production channels need the packaged native helpers; say at boot which ones resolve (src/gate/binary.ts). */
|
|
function checkHelpers(): void {
|
|
for (const name of ["frame-gate", "source-bus"] as const) {
|
|
try { logger.info({ helper: name, path: resolveHelperBinary(name) }, "native helper"); }
|
|
catch (error) { logger.warn({ helper: name, err: String(error) }, "native helper missing: production channels will fail to start"); }
|
|
}
|
|
}
|
|
|
|
async function start(): Promise<void> {
|
|
checkHelpers();
|
|
const desired = readDesired();
|
|
const keyPath = process.env.MEDIA_CONTROL_PUBLIC_KEY;
|
|
const keys = new Map<string, ReturnType<typeof createPublicKey>>();
|
|
if (keyPath) keys.set(process.env.MEDIA_CONTROL_KID ?? "control-v1", createPublicKey(readFileSync(keyPath)));
|
|
const fence = new DurableFence(process.env.MEDIA_FENCE_PATH ?? ".media-worker-state/fence.json");
|
|
bus = await RedisBus.connect(process.env.REDIS_URL ?? `redis://${process.env.REDIS_HOST ?? "localhost"}:6379`);
|
|
supervisor = new MediaSupervisor(workerId, desired, bus, fence, keys, createRelay,
|
|
{ transition: (event) => logger.info(event, "media state transition") });
|
|
await supervisor.start();
|
|
const workspaces = [...new Set(desired.map((item) => item.workspaceId))];
|
|
for (const workspaceId of workspaces) {
|
|
// ACLs should grant only this worker's assigned command channels.
|
|
await bus.subscribe(workerId, [workspaceId], (message) => {
|
|
let command: unknown;
|
|
try { command = JSON.parse(message); } catch { logger.warn("malformed command rejected"); return; }
|
|
void supervisor?.handle(command).catch(() => logger.warn("command rejected"));
|
|
});
|
|
}
|
|
ready = true;
|
|
logger.info({ port, workerId, channels: desired.length }, "media worker listening");
|
|
}
|
|
|
|
server.listen(port, () => { void start().catch((error: unknown) => { logger.error({ err: String(error) }, "media worker startup failed"); process.exitCode = 1; server.close(); }); });
|
|
for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => {
|
|
ready = false;
|
|
void supervisor?.stop().finally(() => { bus?.close(); server.close(); });
|
|
});
|