/** * Local HTTP relay for LAN casting. * * Re-serves decrypted fMP4 segments from the renderer over HTTP so that a * Chromecast, Smart TV or any player on the same Wi-Fi can stream the video. * * The renderer sends raw byte chunks from the WebRTC DataChannel — these are * arbitrary-sized slices of the fMP4 stream, NOT aligned to MP4 box boundaries. * MSE handles this internally, but external players (VLC, Chromecast) need * properly framed fMP4 fragments. The BoxAccumulator reassembles the byte * stream and emits complete moof+mdat pairs. * * Subtitles ride alongside the video as a side-loaded WebVTT file at * `/subs.vtt`. A receiver fetches that one with XHR rather than handing it to * a media element, so unlike the stream it needs CORS headers to be readable * at all. * * Mitigations: * · Bind to the LAN interface, never 0.0.0.0 * · Fixed port range (19550-19553), opened only during active cast * · Unguessable token in the URL (32 hex chars) * · CORS is granted on both served paths, and both stay behind the token * · Cache-Control: no-store on every response * · Server destroyed when playback stops — zero residual surface */ 'use strict'; const crypto = require('node:crypto'); const http = require('node:http'); const os = require('node:os'); const util = require('node:util'); /** * Progress logging, off unless asked for: * * NODE_DEBUG=cast-relay npm start * * Most of what this class has to say repeats without bound — a line every * fiftieth fragment for the length of a film, one per dropped fragment * whenever a client falls behind — and it lands in the terminal the app was * started from. Node's own `debuglog` is used rather than a flag of our own: * it costs nothing when disabled, since the message is never even formatted. * * Nothing that stops a cast is hidden behind it. Every hard failure here * throws — no free port, no such file — and the renderer already reports the * rejection. The one fault that neither throws nor reaches anybody else is a * loss of fMP4 framing, and that one stays on `console.warn`. */ const debug = util.debuglog('cast-relay'); const RING_CAP = 64; const BACKPRESSURE_HIGH = 8 * 1024 * 1024; const MOOF = 0x6d6f6f66; const PORT_BASE = 19550; const PORT_COUNT = 4; // A receiver reads a side-loaded subtitle with XHR, from its own origin, so // the three headers it sends have to be allowed by name — `Range` included, // which it sends even for a document it will read whole. const CORS_HEADERS = Object.freeze({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Accept-Encoding, Range', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range', }); function lanAddress() { const ifaces = os.networkInterfaces(); for (const name of Object.keys(ifaces)) { for (const iface of ifaces[name]) { if (!iface.internal && iface.family === 'IPv4') { return iface.address; } } } return '127.0.0.1'; } class BoxAccumulator { constructor() { this._buf = Buffer.alloc(0); this._synced = false; } push(data) { this._buf = Buffer.concat([this._buf, data]); const fragments = []; if (!this._synced) { const idx = this._findMoof(); if (idx === -1) return fragments; debug(`[cast-relay] box sync: found first moof at byte offset ${idx}, discarded ${idx} bytes`); this._buf = this._buf.subarray(idx); this._synced = true; } while (this._buf.length >= 8) { const size = this._buf.readUInt32BE(0); const type = this._buf.readUInt32BE(4); if (size < 8) { // Not `debug`: the byte stream stopped being framed fMP4. It recovers // by rescanning, so nothing throws and nobody else ever hears about // it — this line is the only trace that the picture on the television // is missing a piece. console.warn(`[cast-relay] box sync lost: invalid size ${size}, rescanning`); this._synced = false; const idx = this._findMoof(); if (idx === -1) return fragments; this._buf = this._buf.subarray(idx); this._synced = true; continue; } if (type === MOOF) { if (this._buf.length < size + 8) break; const mdatSize = this._buf.readUInt32BE(size); const pairSize = size + mdatSize; if (this._buf.length < pairSize) break; fragments.push(Buffer.from(this._buf.subarray(0, pairSize))); this._buf = this._buf.subarray(pairSize); } else { if (this._buf.length < size) break; this._buf = this._buf.subarray(size); } } return fragments; } reset() { this._buf = Buffer.alloc(0); this._synced = false; } _findMoof() { for (let i = 0; i <= this._buf.length - 8; i++) { if (this._buf.readUInt32BE(i + 4) === MOOF) { const size = this._buf.readUInt32BE(i); if (size >= 8 && size < 1_000_000) { return i; } } } return -1; } } class CastRelay { constructor() { this._server = null; this._port = null; this._token = null; this._lanIP = null; this._initSegment = null; this._ring = []; this._clients = new Set(); this._accum = new BoxAccumulator(); this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; this._subtitle = null; this._subtitleVersion = 0; } get active() { return this._server !== null; } get url() { if (!this._server) return null; return `http://${this._lanIP}:${this._port}/stream.mp4?t=${this._token}`; } /** * Where the current subtitle can be fetched, or null when there is none. * * The version is part of the URL because a receiver caches a side-loaded * track by its address: changing the cues behind a fixed URL would leave the * old ones on screen. */ get subtitleUrl() { if (!this._server || !this._subtitle) return null; return `http://${this._lanIP}:${this._port}/subs.vtt` + `?t=${this._token}&v=${this._subtitleVersion}`; } get subtitle() { if (!this._subtitle) return null; return { url: this.subtitleUrl, language: this._subtitle.language, label: this._subtitle.label, }; } /** * Carry a WebVTT document for the receiver to side-load. * * The cues must already be expressed on the *stream's* timeline, which * starts at zero at the seek point — not on the film's. The renderer shifts * them before they get here; the relay only serves bytes. */ setSubtitle(sub) { if (!sub || !sub.vtt) { this._subtitle = null; this._subtitleVersion++; debug('[cast-relay] subtitle cleared'); return null; } this._subtitle = { vtt: Buffer.from(sub.vtt, 'utf8'), language: sub.language || '', label: sub.label || '', }; this._subtitleVersion++; debug(`[cast-relay] subtitle set: ${this._subtitle.vtt.length} bytes` + `, lang "${this._subtitle.language}", v${this._subtitleVersion}`); return this.subtitle; } async start({ codec, initSegment, subtitle }) { if (this._server) await this.stop(); this._token = crypto.randomBytes(16).toString('hex'); this._lanIP = lanAddress(); this._initSegment = initSegment ? Buffer.from(initSegment) : null; this._ring = []; this._clients = new Set(); this._accum = new BoxAccumulator(); this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; this._subtitle = null; this.setSubtitle(subtitle); const server = http.createServer((req, res) => this._handle(req, res)); let bound = false; for (let i = 0; i < PORT_COUNT && !bound; i++) { const port = PORT_BASE + i; try { await new Promise((resolve, reject) => { const onError = (err) => { server.removeListener('error', onError); reject(err); }; server.on('error', onError); server.listen(port, this._lanIP, () => { server.removeListener('error', onError); this._port = port; resolve(); }); }); bound = true; } catch (err) { if (err.code !== 'EADDRINUSE') throw err; debug(`[cast-relay] port ${port} busy, trying next`); } } if (!bound) throw new Error('All cast relay ports are in use'); this._server = server; debug(`[cast-relay] started on ${this.url}`); debug(`[cast-relay] init segment: ${this._initSegment ? this._initSegment.length + ' bytes' : 'none'}`); return { url: this.url, port: this._port, token: this._token, subtitle: this.subtitle, }; } pushSegment(data) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); this._chunkCount++; const fragments = this._accum.push(buf); for (const frag of fragments) { this._fragCount++; if (this._fragCount <= 3 || this._fragCount % 50 === 0) { debug(`[cast-relay] fragment #${this._fragCount}: ${frag.length} bytes (from ${this._chunkCount} chunks), ${this._clients.size} client(s)`); } if (this._ring.length >= RING_CAP) { this._ring.shift(); } this._ring.push(frag); for (const res of this._clients) { if (res.writableLength > BACKPRESSURE_HIGH) { debug(`[cast-relay] backpressure: dropping fragment for slow client`); continue; } res.write(frag); this._bytesSent += frag.length; } } } finish() { debug('[cast-relay] finishing stream'); for (const res of this._clients) { try { res.end(); } catch { /* already closed */ } } } async stop() { debug(`[cast-relay] stopping — ${this._chunkCount} chunks, ${this._fragCount} fragments, ${(this._bytesSent / 1048576).toFixed(1)} MB sent`); for (const res of this._clients) { try { res.end(); } catch { /* already closed */ } } this._clients.clear(); if (this._server) { const srv = this._server; this._server = null; await new Promise((resolve) => srv.close(resolve)); } this._port = null; this._token = null; this._initSegment = null; this._subtitle = null; this._ring = []; this._accum.reset(); this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; } _handle(req, res) { if (req.method !== 'GET' && req.method !== 'OPTIONS') { res.writeHead(405); res.end(); return; } let url; try { url = new URL(req.url, `http://${req.headers.host}`); } catch { res.writeHead(400); res.end(); return; } // The preflight is answered before the token is examined: a browser sends // it without credentials and rejecting it here would read, on the receiver, // as a network failure rather than as a refusal. if (req.method === 'OPTIONS') { res.writeHead(204, CORS_HEADERS); res.end(); return; } if (url.searchParams.get('t') !== this._token) { res.writeHead(403); res.end(); return; } if (url.pathname === '/subs.vtt') { this._handleSubtitle(res); return; } if (url.pathname !== '/stream.mp4') { res.writeHead(404); res.end(); return; } let sent = 0; res.writeHead(200, { // A receiver that has been given a side-loaded track reads the media // through the same CORS-checked path as the track, so the headers go on // both or neither. They widen nothing: the URL is already unguessable, // and a page that has it could embed it in a media element regardless. ...CORS_HEADERS, 'Content-Type': 'video/mp4', 'Cache-Control': 'no-store', 'Accept-Ranges': 'none', 'Connection': 'keep-alive', }); if (this._initSegment) { res.write(this._initSegment); sent += this._initSegment.length; } for (const frag of this._ring) { res.write(frag); sent += frag.length; } debug(`[cast-relay] client connected from ${req.socket.remoteAddress} — sent init + ${this._ring.length} fragments (${(sent / 1024).toFixed(0)} KB)`); this._clients.add(res); req.on('close', () => { this._clients.delete(res); debug(`[cast-relay] client disconnected, ${this._clients.size} remaining`); }); } _handleSubtitle(res) { if (!this._subtitle) { res.writeHead(404, CORS_HEADERS); res.end(); return; } res.writeHead(200, { ...CORS_HEADERS, 'Content-Type': 'text/vtt; charset=utf-8', 'Content-Length': this._subtitle.vtt.length, 'Cache-Control': 'no-store', }); res.end(this._subtitle.vtt); debug(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`); } } module.exports = CastRelay;