T-HUB/packages/egress/src/hls.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

104 lines
5.2 KiB
TypeScript

import { EgressError } from "./errors.js";
import { EgressPolicy, type ValidatedEndpoint } from "./policy.js";
interface UriOccurrence { start: number; end: number; value: string }
/** Parse attribute boundaries rather than finding URI= inside other quoted values. */
function attributeUris(attributes: string, offset: number): UriOccurrence[] {
const uris: UriOccurrence[] = [];
if (!/^\s*[A-Z0-9-]+\s*=/.test(attributes)) return uris;
let cursor = 0;
const seen = new Set<string>();
while (cursor < attributes.length) {
const key = /^\s*([A-Z0-9-]+)\s*=\s*/.exec(attributes.slice(cursor));
if (!key) throw new EgressError("INVALID_PLAYLIST", "Malformed HLS attributes");
cursor += key[0].length;
const quoted = attributes[cursor] === '"';
const start = cursor + (quoted ? 1 : 0);
const end = quoted ? attributes.indexOf('"', start) : attributes.indexOf(",", start);
const valueEnd = end < 0 && !quoted ? attributes.length : end;
if (valueEnd < 0) throw new EgressError("INVALID_PLAYLIST", "Unterminated HLS attribute");
if (key[1] === "URI" || key[1] === "SERVER-URI") {
if (!quoted || start === valueEnd || seen.has(key[1])) throw new EgressError("INVALID_PLAYLIST", "Invalid HLS URI attribute");
seen.add(key[1]);
uris.push({ start: offset + start, end: offset + valueEnd, value: attributes.slice(start, valueEnd) });
}
cursor = valueEnd + (quoted ? 1 : 0);
while (/\s/.test(attributes[cursor] ?? "") && cursor < attributes.length) cursor++;
if (cursor === attributes.length) break;
if (attributes[cursor] !== "," || cursor + 1 === attributes.length) throw new EgressError("INVALID_PLAYLIST", "Invalid HLS attribute suffix");
cursor++;
}
return uris;
}
function playlistUris(playlist: string): { uris: UriOccurrence[]; master: boolean } {
if (!/^#EXTM3U(?:\r?\n|$)/.test(playlist) || playlist.includes("\u0000")) throw new EgressError("INVALID_PLAYLIST", "Missing HLS header");
const uris: UriOccurrence[] = [];
let offset = 0;
let awaitingVariant = false;
let variants = 0;
let media = false;
for (const line of playlist.split(/(?<=\n)/)) {
const text = line.replace(/\r?\n$/, "").trim();
if (text && !text.startsWith("#")) {
const start = offset + line.indexOf(text);
uris.push({ start, end: start + text.length, value: text });
if (awaitingVariant) { variants++; awaitingVariant = false; }
} else if (text.startsWith("#EXT")) {
if (text.startsWith("#EXTINF:")) media = true;
if (text.startsWith("#EXT-X-STREAM-INF:")) {
if (awaitingVariant) throw new EgressError("INVALID_PLAYLIST", "Variant URI is missing");
awaitingVariant = true;
}
const colon = line.indexOf(":");
if (colon >= 0) {
const attributes = line.slice(colon + 1).replace(/\r?\n$/, "");
const attributesFound = attributeUris(attributes, offset + colon + 1);
uris.push(...attributesFound);
if (text.startsWith("#EXT-X-I-FRAME-STREAM-INF:")) {
if (!attributesFound.length) throw new EgressError("INVALID_PLAYLIST", "I-frame URI is missing");
variants++;
}
}
}
offset += line.length;
}
if (awaitingVariant) throw new EgressError("INVALID_PLAYLIST", "Variant URI is missing");
return { uris, master: variants > 0 && !media };
}
async function resolveUris(uris: UriOccurrence[], base: string | URL, policy: EgressPolicy): Promise<ValidatedEndpoint[]> {
const baseUrl = (await policy.validate(base)).url;
const endpoints: ValidatedEndpoint[] = [];
// Sequential validation bounds DNS work even for large playlists; shared URI pins within this parse.
const cache = new Map<string, ValidatedEndpoint>();
for (const occurrence of uris) {
if (!occurrence.value || occurrence.value.includes("{$") || /[\s\\\u0000-\u001f\u007f]/u.test(occurrence.value)) throw new EgressError("INVALID_PLAYLIST", "Invalid or unexpanded HLS URI");
let absolute: URL;
try { absolute = new URL(occurrence.value, baseUrl); } catch { throw new EgressError("INVALID_PLAYLIST", "Invalid HLS URI"); }
let endpoint = cache.get(absolute.href);
if (!endpoint) { endpoint = await policy.validate(absolute); cache.set(absolute.href, endpoint); }
endpoints.push(endpoint);
}
return endpoints;
}
/** Validates master variants and URI-bearing tags (renditions, keys, maps, etc.) with DNS pinning. */
export async function validateMasterPlaylist(playlist: string, base: string | URL, policy = new EgressPolicy()): Promise<ValidatedEndpoint[]> {
const { uris, master } = playlistUris(playlist);
if (!master) throw new EgressError("INVALID_PLAYLIST", "Expected an HLS master playlist");
return resolveUris(uris, base, policy);
}
/** Produces absolute, validated child URLs, preserving comments, attributes and newline style. */
export async function rewriteSegmentUris(playlist: string, base: string | URL, policy = new EgressPolicy()): Promise<string> {
const { uris } = playlistUris(playlist);
const endpoints = await resolveUris(uris, base, policy);
let rewritten = playlist;
for (let index = uris.length - 1; index >= 0; index--) {
const uri = uris[index]!;
rewritten = rewritten.slice(0, uri.start) + endpoints[index]!.url.href + rewritten.slice(uri.end);
}
return rewritten;
}