Files

237 lines
12 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
/*
* follower-link.js — the relay that lets a second screen mirror the stage TV exactly.
*
* WHY THIS EXISTS: the booth needs the Bible TV's picture in ProPresenter (and on to the ATEM)
* without running a video cable out of the stage PC and without pushing video over Wi-Fi. So no
* pixels are sent at all. The stage renderer publishes STATE — which tab, which passage, the scroll
* position, and every pen stroke as vectors — and a browser on the booth machine re-renders the same
* page natively at full resolution and frame rate. A few KB/s of JSON replaces ~250 Mbit/s of video,
* and a lost packet costs a stale frame rather than a torn picture.
*
* stage renderer (follower-publish.js) ──WS /follower-ws?role=publisher──▶ here
* │ fan-out
* booth browser (index.html?follower=1) ◀─WS /follower-ws?role=subscriber───┘
*
* Two message classes, deliberately separated:
* - `snapshot` — the whole UI state (tab, passage, geometry, committed strokes). Retained, so a
* follower that connects or reconnects mid-service is correct within one frame.
* - everything else (`stroke:*`, `scroll`) — high-rate deltas, relayed and forgotten.
*
* The retained snapshot is the reason a follower can be power-cycled mid-sermon and come back right.
*
* Only ONE publisher is honoured at a time (the newest wins): two publishers would interleave
* contradictory states, and on a stage that shows up as text flickering between two passages. A
* second copy of the app opened by accident is therefore harmless — it simply takes over, and the
* real TV's next snapshot (published every 250ms) takes it back.
*/
import { WebSocketServer } from 'ws';
const PATH = '/follower-ws';
export function attachFollowerLink(server, opts = {}) {
const log = opts.log || ((...a) => console.log('[FOLLOWER]', ...a));
// Settings › Follower Screen switch. Off = no screen may subscribe; anyone connected is dropped.
const isEnabled = typeof opts.isEnabled === 'function' ? opts.isEnabled : () => true;
const wss = new WebSocketServer({ noServer: true });
let publisher = null; // the stage renderer
const subscribers = new Set(); // booth screens
let snapshot = null; // last full state, replayed to anyone who joins
let seq = 0;
const stats = { published: 0, relayed: 0, dropped: 0, subscribersPeak: 0, startedAt: Date.now() };
function sendTo(ws, obj) {
// readyState 1 = OPEN. Sending to a socket that is closing throws and would kill the relay.
if (!ws || ws.readyState !== 1) return false;
try { ws.send(typeof obj === 'string' ? obj : JSON.stringify(obj)); return true; } catch (_) { return false; }
}
function fanOut(raw) {
stats.relayed++;
for (const ws of subscribers) {
/*
* Never queue behind a slow screen. A booth machine that stalls (or a Wi-Fi hiccup) would
* otherwise build an unbounded backlog and then replay a burst of stale stroke points —
* the line would visibly redraw itself late. Skipping while the buffer is deep keeps the
* follower in the present; the next snapshot repairs whatever it missed.
*/
if (ws.bufferedAmount > 1 << 20) { stats.dropped++; ws._missed = true; continue; }
/*
* It missed something. A snapshot only CARRIES the stroke list when it changed, so a skipped
* clear-all or a skipped restored list would otherwise never be repaired while the socket
* stays open. Hand it the completed retained snapshot (which always carries the list) before
* the current message.
*/
if (ws._missed) { ws._missed = false; if (snapshot) sendTo(ws, snapshot); }
sendTo(ws, raw);
}
}
/*
* Last known committed strokes per passage. A snapshot only CARRIES the stroke list when it changed
* (bandwidth), so the retained snapshot handed to a late joiner could otherwise lack it — and a
* follower that reconnected after a Wi-Fi blip would show the chapter with no ink until the next
* stroke. The retained copy is completed from here before it is stored.
*/
const lastStrokes = new Map();
function remember(msg) {
if (msg && msg.passageKey && Array.isArray(msg.strokes)) lastStrokes.set(msg.passageKey, msg.strokes);
}
function complete(msg) {
if (msg && msg.passageKey && !Array.isArray(msg.strokes) && lastStrokes.has(msg.passageKey)) {
msg.strokes = lastStrokes.get(msg.passageKey);
}
return msg;
}
/*
* Only the machine this server runs on may publish. The stage renderer connects to its own server
* over loopback; anything arriving from a LAN address is some other copy of the app — a browser
* peeking at the stage URL, a booth screen, the admin's laptop. Letting it publish would hijack the
* program feed: two publishers alternate as each reconnects and the follower flips between their
* states. (Exactly what happened once with a forgotten test tab.) Refused here regardless of what
* the client asks for, so no client-side mistake can ever cause it again.
*/
function isLoopback(req) {
const a = String((req.socket && req.socket.remoteAddress) || '');
return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1' || a.startsWith('127.');
}
/*
* Second line of defence, for clients this server did not ship: `stage=1` is added to the URL only
* by the Electron stage window (follower-publish.js). A browser tab on the stage machine running an
* OLD cached copy of the app is loopback AND still publishes (it predates the client-side gate) —
* it did exactly that once, alternating with the real stage 229 times in eight minutes and making
* the booth feed flip between two states. So: while a marked stage publisher is connected, every
* unmarked publisher is refused; a marked one always displaces an unmarked one; and only another
* marked publisher (a second Electron window — should not happen) can displace a marked one.
*/
let publisherIsStage = false;
wss.on('connection', (ws, req) => {
let role = 'subscriber', marked = false;
try {
const q = new URL(req.url, 'http://x').searchParams;
role = q.get('role') === 'publisher' ? 'publisher' : 'subscriber';
marked = q.get('stage') === '1';
} catch (_) {}
if (role === 'publisher' && !isLoopback(req)) {
log(`refused publisher from ${req.socket && req.socket.remoteAddress} — only the stage machine may publish`);
stats.refusedPublishers = (stats.refusedPublishers || 0) + 1;
try { ws.close(4003, 'publisher must be local'); } catch (_) {}
return;
}
if (role === 'publisher') {
if (publisher && publisher !== ws) {
if (publisherIsStage && !marked) {
log('refused an unmarked local publisher (an old browser copy of the app?) — the stage window is already publishing');
stats.refusedPublishers = (stats.refusedPublishers || 0) + 1;
try { ws.close(4004, 'stage window is publishing'); } catch (_) {}
return;
}
log(`WARNING: a ${marked ? 'stage' : 'local'} publisher replaced the previous ${publisherIsStage ? 'stage' : 'local'} one`);
stats.publisherReplacements = (stats.publisherReplacements || 0) + 1;
try { publisher.close(4000, 'replaced'); } catch (_) {}
}
publisher = ws;
publisherIsStage = marked;
// A new stage session: nothing the previous one published (its ink, its passage) may be handed to
// a screen that joins now. The new publisher's first snapshot arrives within a tick.
lastStrokes.clear();
snapshot = null;
log(`${marked ? 'stage renderer' : 'local publisher (unmarked)'} connected`);
} else {
if (!isEnabled()) {
stats.refusedSubscribers = (stats.refusedSubscribers || 0) + 1;
try { ws.close(4005, 'follower screen is turned off in Settings'); } catch (_) {}
return;
}
subscribers.add(ws);
stats.subscribersPeak = Math.max(stats.subscribersPeak, subscribers.size);
log(`follower connected (${subscribers.size} watching)`);
// Make the new screen correct immediately instead of at the next state change.
if (snapshot) sendTo(ws, snapshot);
else sendTo(ws, JSON.stringify({ type: 'waiting' }));
}
ws.on('message', (data, isBinary) => {
if (isBinary || ws !== publisher) return; // only the stage renderer may publish
const raw = data.toString();
stats.published++;
// Peek at the type without parsing the whole payload on the hot path: stroke points are
// relayed byte-for-byte, and only a snapshot needs to be retained.
if (raw.indexOf('"snapshot"') !== -1 || raw.indexOf('"commit"') !== -1 || raw.indexOf('"clearAll"') !== -1) {
try {
const msg = JSON.parse(raw);
if (msg && msg.type === 'clearAll') {
// The stage wiped every passage. Forget every remembered list, and strip the ink
// from the retained snapshot so a screen joining right now gets none of the old marks.
lastStrokes.clear();
if (snapshot) { try { const s = JSON.parse(snapshot); s.strokes = []; snapshot = JSON.stringify(s); } catch (_) {} }
fanOut(raw);
return;
}
if (msg && msg.type === 'snapshot') {
msg.seq = ++seq;
remember(msg);
// Live subscribers already hold the strokes; only the RETAINED copy is completed.
fanOut(JSON.stringify(msg));
snapshot = JSON.stringify(complete(msg));
return;
}
if (msg && msg.type === 'commit') { remember(msg); fanOut(raw); return; }
} catch (_) { /* fall through and relay as-is */ }
}
fanOut(raw);
});
ws.on('close', () => {
if (ws === publisher) { publisher = null; publisherIsStage = false; log('publisher disconnected'); }
else if (subscribers.delete(ws)) log(`follower disconnected (${subscribers.size} watching)`);
});
ws.on('error', () => { try { ws.close(); } catch (_) {} });
});
server.on('upgrade', (req, socket, head) => {
let pathname = '';
try { pathname = new URL(req.url, 'http://x').pathname; } catch (_) {}
if (pathname !== PATH) return; // leave every other upgrade alone
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
/*
* Heartbeat. A booth screen that loses Wi-Fi leaves a half-open socket that never fires 'close',
* so without this the relay would keep "successfully" writing stroke points into a dead pipe and
* report a follower that is not there.
*/
const beat = setInterval(() => {
for (const ws of [publisher, ...subscribers]) { if (ws) { try { ws.ping(); } catch (_) {} } }
}, 15000);
try { beat.unref(); } catch (_) {}
return {
// Called when the operator flips the Settings switch. Turning it off drops every screen at once,
// so the program feed goes black immediately rather than at the next reconnect.
applyEnabled() {
if (isEnabled()) return;
for (const ws of subscribers) { try { ws.close(4005, 'follower screen is turned off in Settings'); } catch (_) {} }
subscribers.clear();
},
status() {
return {
enabled: isEnabled(),
publisher: !!publisher,
publisherIsStage,
followers: subscribers.size,
hasSnapshot: !!snapshot,
seq,
...stats,
uptimeMs: Date.now() - stats.startedAt
};
}
};
}