feat(live-news): add CNN & CNBC HLS streams via sidecar proxy (#682)

* feat(live-news): add CNN & CNBC HLS streams via sidecar proxy (desktop only)

Add /api/hls-proxy route to sidecar that proxies HLS manifests and
segments from allowlisted CDN hosts, injecting the required Referer
header that browsers cannot set. Rewrites m3u8 URLs so all segments
and encryption keys also route through the proxy.

Desktop gets native <video> HLS playback for CNN and CNBC; web falls
through to YouTube as before (no bandwidth cost on Vercel).

* fix(types): add missing @types/dompurify dev dependency
This commit is contained in:
Elie Habib
2026-03-01 21:06:18 +04:00
committed by GitHub
parent 77b326397f
commit 078a239ceb
4 changed files with 89 additions and 19 deletions

19
package-lock.json generated
View File

@@ -7353,18 +7353,13 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.2.0",
"domhandler": "^4.2.0"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {

View File

@@ -58,8 +58,8 @@
"@types/canvas-confetti": "^1.9.0",
"@types/d3": "^7.4.3",
"@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2",
"@types/maplibre-gl": "^1.13.2",
"@types/marked": "^5.0.2",
"@types/papaparse": "^5.5.2",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",

View File

@@ -940,6 +940,64 @@ async function dispatch(requestUrl, req, routes, context) {
return handleLocalServiceStatus(context);
}
// HLS proxy — exempt from auth because <video src="..."> cannot carry
// custom headers. Proxies HLS manifests and segments from allowlisted CDN
// hosts, adding the required Referer header that browsers cannot set.
// Desktop-only (sidecar); web uses YouTube fallback.
if (requestUrl.pathname === '/api/hls-proxy') {
const ALLOWED_HLS_HOSTS = new Set(['cdn-ca2-na.lncnetworks.host']);
const upstreamRaw = requestUrl.searchParams.get('url');
if (!upstreamRaw) return new Response('Missing url param', { status: 400, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
let upstream;
try { upstream = new URL(upstreamRaw); } catch { return new Response('Invalid url', { status: 400, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } }); }
if (upstream.protocol !== 'https:' || !ALLOWED_HLS_HOSTS.has(upstream.hostname)) {
return new Response('Host not allowed', { status: 403, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
}
try {
const hlsResp = await new Promise((resolve, reject) => {
const reqOpts = {
hostname: upstream.hostname,
port: 443,
path: upstream.pathname + upstream.search,
method: 'GET',
headers: { 'Referer': 'https://livenewschat.eu/', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' },
family: 4,
};
const r = https.request(reqOpts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
});
r.on('error', reject);
r.setTimeout(10000, () => r.destroy(new Error('HLS upstream timeout')));
r.end();
});
if (hlsResp.status < 200 || hlsResp.status >= 300) {
return new Response(`Upstream ${hlsResp.status}`, { status: hlsResp.status, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
}
const ct = hlsResp.headers['content-type'] || '';
const isManifest = upstreamRaw.endsWith('.m3u8') || ct.includes('mpegurl') || ct.includes('x-mpegurl');
if (isManifest) {
const basePath = upstream.pathname.substring(0, upstream.pathname.lastIndexOf('/') + 1);
const baseOrigin = upstream.origin;
let manifest = hlsResp.body.toString('utf-8');
manifest = manifest.replace(/^(?!#)(\S+)/gm, (match) => {
const full = match.startsWith('http') ? match : `${baseOrigin}${basePath}${match}`;
return `/api/hls-proxy?url=${encodeURIComponent(full)}`;
});
manifest = manifest.replace(/URI="([^"]+)"/g, (_m, uri) => {
const full = uri.startsWith('http') ? uri : `${baseOrigin}${basePath}${uri}`;
return `URI="/api/hls-proxy?url=${encodeURIComponent(full)}"`;
});
return new Response(manifest, { status: 200, headers: { 'content-type': 'application/vnd.apple.mpegurl', 'cache-control': 'no-cache', ...makeCorsHeaders(req) } });
}
return new Response(hlsResp.body, { status: 200, headers: { 'content-type': ct || 'application/octet-stream', 'cache-control': 'no-cache', ...makeCorsHeaders(req) } });
} catch (e) {
context.logger.warn('[hls-proxy] error:', e.message);
return new Response('Proxy error', { status: 502, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
}
}
// YouTube embed bridge — exempt from auth because iframe src cannot carry
// Authorization headers. Serves a minimal HTML page that loads the YouTube
// IFrame Player API from a localhost origin (which YouTube accepts, unlike

View File

@@ -63,6 +63,7 @@ const FULL_LIVE_CHANNELS: LiveChannel[] = [
{ id: 'euronews', name: 'Euronews', handle: '@euronews', fallbackVideoId: 'pykpO5kQJ98' },
{ id: 'dw', name: 'DW', handle: '@DWNews', fallbackVideoId: 'LuKwFajn37U' },
{ id: 'cnbc', name: 'CNBC', handle: '@CNBC', fallbackVideoId: '9NyxcX3rhQs' },
{ id: 'cnn', name: 'CNN', handle: '@CNN', fallbackVideoId: 'w_Ma8oQLmSM' },
{ id: 'france24', name: 'France24', handle: '@France24_en', fallbackVideoId: 'Ap-UM1O9RBU' },
{ id: 'alarabiya', name: 'AlArabiya', handle: '@AlArabiya', fallbackVideoId: 'n7eQejkXbnM', useFallbackOnly: true },
{ id: 'aljazeera', name: 'AlJazeera', handle: '@AlJazeeraEnglish', fallbackVideoId: 'gCNeDWCI0vo', useFallbackOnly: true },
@@ -79,6 +80,7 @@ const TECH_LIVE_CHANNELS: LiveChannel[] = [
// Optional channels users can add from the "Available Channels" tab UI
export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [
// North America
{ id: 'cnn', name: 'CNN', handle: '@CNN', fallbackVideoId: 'w_Ma8oQLmSM' },
{ id: 'livenow-fox', name: 'LiveNOW from FOX', handle: '@LiveNOWfromFOX', fallbackVideoId: 'QaftgYkG-ek' },
{ id: 'fox-news', name: 'Fox News', handle: '@FoxNews', fallbackVideoId: 'QaftgYkG-ek', useFallbackOnly: true },
{ id: 'newsmax', name: 'Newsmax', handle: '@NEWSMAX', fallbackVideoId: 'S-lFBzloL2Y', useFallbackOnly: true },
@@ -145,7 +147,7 @@ export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [
];
export const OPTIONAL_CHANNEL_REGIONS: { key: string; labelKey: string; channelIds: string[] }[] = [
{ key: 'na', labelKey: 'components.liveNews.regionNorthAmerica', channelIds: ['livenow-fox', 'fox-news', 'newsmax', 'abc-news', 'cbs-news', 'nbc-news', 'cbc-news'] },
{ key: 'na', labelKey: 'components.liveNews.regionNorthAmerica', channelIds: ['cnn', 'livenow-fox', 'fox-news', 'newsmax', 'abc-news', 'cbs-news', 'nbc-news', 'cbc-news'] },
{ key: 'eu', labelKey: 'components.liveNews.regionEurope', channelIds: ['bbc-news', 'france24-en', 'welt', 'rtve', 'trt-haber', 'ntv-turkey', 'cnn-turk', 'tv-rain', 'rt', 'tvp-info', 'telewizja-republika', 'tagesschau24', 'tv5monde-info', 'nrk1', 'aljazeera-balkans'] },
{ key: 'latam', labelKey: 'components.liveNews.regionLatinAmerica', channelIds: ['cnn-brasil', 'jovem-pan', 'record-news', 'band-jornalismo', 'tn-argentina', 'c5n', 'milenio', 'noticias-caracol', 'ntn24', 't13'] },
{ key: 'asia', labelKey: 'components.liveNews.regionAsia', channelIds: ['tbs-news', 'ann-news', 'ntv-news', 'cti-news', 'wion', 'ndtv', 'cna-asia', 'nhk-world', 'arirang-news', 'india-today', 'abp-news'] },
@@ -201,6 +203,12 @@ const DIRECT_HLS_MAP: Readonly<Record<string, string>> = {
'arirang-news': 'https://amdlive-ch01-ctnd-com.akamaized.net/arirang_1ch/smil:arirang_1ch.smil/playlist.m3u8',
};
interface ProxiedHlsEntry { url: string; referer: string; }
const PROXIED_HLS_MAP: Readonly<Record<string, ProxiedHlsEntry>> = {
'cnbc': { url: 'https://cdn-ca2-na.lncnetworks.host/hls/cnbc_live/index.m3u8', referer: 'https://livenewschat.eu/' },
'cnn': { url: 'https://cdn-ca2-na.lncnetworks.host/hls/cnn_live/index.m3u8', referer: 'https://livenewschat.eu/' },
};
if (import.meta.env.DEV) {
const allChannels = [...FULL_LIVE_CHANNELS, ...TECH_LIVE_CHANNELS, ...OPTIONAL_LIVE_CHANNELS];
for (const id of Object.keys(DIRECT_HLS_MAP)) {
@@ -387,6 +395,15 @@ export class LiveNewsPanel extends Panel {
return url;
}
private getProxiedHlsUrl(channelId: string): string | undefined {
if (!isDesktopRuntime()) return undefined;
const entry = PROXIED_HLS_MAP[channelId];
if (!entry) return undefined;
const failedAt = this.hlsFailureCooldown.get(channelId);
if (failedAt && Date.now() - failedAt < this.HLS_COOLDOWN_MS) return undefined;
return `http://127.0.0.1:${getLocalApiPort()}/api/hls-proxy?url=${encodeURIComponent(entry.url)}`;
}
private get embedOrigin(): string {
if (isDesktopRuntime()) return `http://localhost:${getLocalApiPort()}`;
try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; }
@@ -774,7 +791,7 @@ export class LiveNewsPanel extends Panel {
private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise<void> {
const useFallbackVideo = channel.useFallbackOnly || forceFallback;
if (isDesktopRuntime() && this.getDirectHlsUrl(channel.id)) {
if (isDesktopRuntime() && (this.getDirectHlsUrl(channel.id) || this.getProxiedHlsUrl(channel.id))) {
channel.videoId = channel.fallbackVideoId;
channel.isLive = true;
return;
@@ -815,7 +832,7 @@ export class LiveNewsPanel extends Panel {
}
});
if (this.getDirectHlsUrl(channel.id)) {
if (this.getDirectHlsUrl(channel.id) || this.getProxiedHlsUrl(channel.id)) {
this.renderNativeHlsPlayer();
return;
}
@@ -964,8 +981,8 @@ export class LiveNewsPanel extends Panel {
}
private renderNativeHlsPlayer(): void {
const hlsUrl = this.getDirectHlsUrl(this.activeChannel.id);
if (!hlsUrl || !hlsUrl.startsWith('https://')) return;
const hlsUrl = this.getDirectHlsUrl(this.activeChannel.id) || this.getProxiedHlsUrl(this.activeChannel.id);
if (!hlsUrl || !(hlsUrl.startsWith('https://') || hlsUrl.startsWith('http://127.0.0.1'))) return;
this.destroyPlayer();
this.ensurePlayerContainer();
@@ -1105,7 +1122,7 @@ export class LiveNewsPanel extends Panel {
this.forceFallbackVideoForNextInit = false;
await this.resolveChannelVideo(this.activeChannel, useFallbackVideo);
if (this.getDirectHlsUrl(this.activeChannel.id)) {
if (this.getDirectHlsUrl(this.activeChannel.id) || this.getProxiedHlsUrl(this.activeChannel.id)) {
this.renderNativeHlsPlayer();
return;
}