2026-10-14 20:12:23 -04:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Companion Buttons — server part.
|
|
|
|
|
|
*
|
2026-10-15 11:48:24 -04:00
|
|
|
|
* Shows Bitfocus Companion pages on dashboard tiles, every button exactly as
|
|
|
|
|
|
* Companion draws it: colours, text, icons and live feedback included.
|
|
|
|
|
|
* ProdDash holds one connection to Companion (companion.js) however many
|
|
|
|
|
|
* dashboards are open; browsers only ever talk to these routes.
|
2026-10-14 20:12:23 -04:00
|
|
|
|
*
|
2026-10-15 11:48:24 -04:00
|
|
|
|
* Each Companion page is an entry in the + Add-tile picker (tiles()), titled
|
|
|
|
|
|
* with the page's name. A tile shows its whole page, or the buttons chosen on
|
|
|
|
|
|
* it (the tile's own choosing mode).
|
2026-10-14 20:12:23 -04:00
|
|
|
|
*
|
2026-10-15 11:48:24 -04:00
|
|
|
|
* GET /page-options → the pages, for a tile added without one
|
|
|
|
|
|
* GET /stream/<page>[/<r.c>/…]
|
|
|
|
|
|
* → SSE for one tile: `page` (what to lay out) on
|
|
|
|
|
|
* connect and whenever it changes, `button` (one
|
|
|
|
|
|
* picture) on connect and on every redraw
|
|
|
|
|
|
* GET /state/<page>[/<r.c>/…]
|
|
|
|
|
|
* → the same as one snapshot, for a tile that joins
|
|
|
|
|
|
* a stream already open on its dashboard
|
2026-10-14 20:12:23 -04:00
|
|
|
|
*
|
2026-10-15 11:48:24 -04:00
|
|
|
|
* <page> is Companion's own page id, which stays with the page when pages are
|
|
|
|
|
|
* inserted or moved; <r.c> is a button's row and column on it. Buttons are
|
|
|
|
|
|
* followed by place, so a page re-imported from a .companionconfig — every
|
|
|
|
|
|
* button gets a new id — keeps showing. A tile holds one stream for all its
|
|
|
|
|
|
* buttons: ProdDash carries at most 32 streams per page.
|
2026-10-14 20:12:23 -04:00
|
|
|
|
*
|
|
|
|
|
|
* Read-only by design: nothing here can press a button or change Companion.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const { createLink, createPageBook, NAV_TYPES } = require('./companion');
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
|
|
|
|
|
const HEARTBEAT_MS = 15000;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves
|
|
|
|
|
|
const DESCRIBE_MS = 3000; // how long to wait for one button's setup
|
|
|
|
|
|
const MAX_SPOTS = 64;
|
|
|
|
|
|
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
|
|
|
const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
|
|
|
|
|
let current = null;
|
|
|
|
|
|
|
|
|
|
|
|
function endpointOf(v) {
|
|
|
|
|
|
const ep = v && typeof v === 'object' ? v : {};
|
|
|
|
|
|
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 8000 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sendJson(res, code, body) {
|
|
|
|
|
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
|
|
|
|
res.end(JSON.stringify(body));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/** "/<page>" → { pageId, keys: null } (the whole page); "/<page>/0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */
|
|
|
|
|
|
function viewFrom(req) {
|
|
|
|
|
|
const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/');
|
|
|
|
|
|
if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null;
|
|
|
|
|
|
if (!rest.length) return { pageId, keys: null };
|
|
|
|
|
|
if (!rest.every((k) => SPOT_RE.test(k))) return null;
|
|
|
|
|
|
return { pageId, keys: [...new Set(rest)] };
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
|
init({ config, log }) {
|
|
|
|
|
|
const { host, port } = endpointOf(config.companion);
|
|
|
|
|
|
const pages = createPageBook();
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const buttons = new Map(); // controlId → { image, seen, refs, linger, off }
|
|
|
|
|
|
const known = new Map(); // controlId → { type }
|
|
|
|
|
|
const describing = new Map(); // controlId → Promise
|
|
|
|
|
|
const viewers = new Set(); // { res, pageId, keys, spots: [{ key, row, column, target }], sig }
|
2026-10-14 20:12:23 -04:00
|
|
|
|
let link = null;
|
|
|
|
|
|
let heartbeat = null;
|
|
|
|
|
|
let status = host
|
|
|
|
|
|
? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` }
|
|
|
|
|
|
: { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" };
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/* — what kind of button each is (read once per button; navigation is never shown) — */
|
|
|
|
|
|
|
|
|
|
|
|
function describe(controlId) {
|
|
|
|
|
|
if (!link) return Promise.resolve(null);
|
|
|
|
|
|
if (describing.has(controlId)) return describing.get(controlId);
|
|
|
|
|
|
const p = new Promise((resolve) => {
|
|
|
|
|
|
let off = null;
|
|
|
|
|
|
const finish = (info) => {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
if (off) off();
|
|
|
|
|
|
off = null;
|
|
|
|
|
|
describing.delete(controlId);
|
|
|
|
|
|
if (info) known.set(controlId, info);
|
|
|
|
|
|
resolve(info);
|
|
|
|
|
|
};
|
|
|
|
|
|
const timer = setTimeout(() => finish(null), DESCRIBE_MS);
|
|
|
|
|
|
off = link.subscribe(
|
|
|
|
|
|
'controls.watchControl',
|
|
|
|
|
|
{ controlId },
|
|
|
|
|
|
(data) => {
|
|
|
|
|
|
if (!data || data.type !== 'init') return;
|
|
|
|
|
|
const cfg = data.config || {};
|
|
|
|
|
|
finish({ type: String(cfg.type || '') });
|
|
|
|
|
|
},
|
|
|
|
|
|
() => finish(null)
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
describing.set(controlId, p);
|
|
|
|
|
|
return p;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Learn every button Companion has that is not known yet, a few at a time. */
|
|
|
|
|
|
let learning = false;
|
|
|
|
|
|
async function learnAll() {
|
|
|
|
|
|
if (learning) return;
|
|
|
|
|
|
learning = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
|
const todo = [];
|
|
|
|
|
|
for (const p of pages.list()) for (const b of p.buttons) if (!known.has(b.controlId)) todo.push(b.controlId);
|
|
|
|
|
|
if (!todo.length || !link || status.state !== 'ok') break;
|
|
|
|
|
|
for (let i = 0; i < todo.length; i += 16) {
|
|
|
|
|
|
await Promise.all(todo.slice(i, i + 16).map(describe));
|
|
|
|
|
|
}
|
|
|
|
|
|
sendAll(); // nav buttons now known: whole-page tiles drop them
|
|
|
|
|
|
if (todo.every((id) => !known.has(id))) break; // Companion is not answering; try again on the next change
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
learning = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isNav = (controlId) => NAV_TYPES.has(known.get(controlId)?.type);
|
|
|
|
|
|
|
|
|
|
|
|
/* — following a button's picture — */
|
2026-10-14 20:12:23 -04:00
|
|
|
|
|
|
|
|
|
|
function watch(controlId) {
|
|
|
|
|
|
let b = buttons.get(controlId);
|
|
|
|
|
|
if (!b) {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
b = { image: null, seen: false, refs: 0, linger: null, off: null };
|
2026-10-14 20:12:23 -04:00
|
|
|
|
buttons.set(controlId, b);
|
|
|
|
|
|
if (link) {
|
|
|
|
|
|
b.off = link.subscribe(
|
|
|
|
|
|
'preview.graphics.controlId',
|
|
|
|
|
|
{ controlId },
|
|
|
|
|
|
(data) => {
|
|
|
|
|
|
const image = typeof data === 'string' && data.startsWith('data:image/') ? data : null;
|
|
|
|
|
|
const changed = image !== b.image || !b.seen;
|
|
|
|
|
|
b.image = image;
|
|
|
|
|
|
b.seen = true;
|
|
|
|
|
|
// Status buttons redraw every second with the same picture; only a real change goes out.
|
|
|
|
|
|
if (changed) sendButton(controlId);
|
|
|
|
|
|
},
|
|
|
|
|
|
(message) => log(`button ${controlId}: ${message}`)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
clearTimeout(b.linger);
|
|
|
|
|
|
b.linger = null;
|
|
|
|
|
|
return b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function release(controlId) {
|
|
|
|
|
|
const b = buttons.get(controlId);
|
2026-10-15 11:48:24 -04:00
|
|
|
|
if (!b || b.refs > 0) return;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
clearTimeout(b.linger);
|
|
|
|
|
|
b.linger = setTimeout(() => {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
if (b.refs > 0) return;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
if (b.off) b.off();
|
|
|
|
|
|
buttons.delete(controlId);
|
|
|
|
|
|
}, LINGER_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/* — one tile's view of one page — */
|
|
|
|
|
|
|
|
|
|
|
|
/** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */
|
|
|
|
|
|
function spotsFor(viewer, page) {
|
|
|
|
|
|
if (!page) return [];
|
|
|
|
|
|
const at = new Map(page.buttons.map((b) => [`${b.row}.${b.column}`, b]));
|
|
|
|
|
|
const keys = viewer.keys || page.buttons.filter((b) => !isNav(b.controlId)).map((b) => `${b.row}.${b.column}`);
|
|
|
|
|
|
return keys.map((key) => {
|
|
|
|
|
|
const [row, column] = key.split('.').map(Number);
|
|
|
|
|
|
return { key, row, column, target: at.get(key)?.controlId || null };
|
|
|
|
|
|
});
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
function pageBody(spots, page) {
|
|
|
|
|
|
return {
|
2026-10-14 20:12:23 -04:00
|
|
|
|
status: status.state,
|
|
|
|
|
|
message: status.message,
|
2026-10-15 11:48:24 -04:00
|
|
|
|
found: Boolean(page) || !pages.loaded,
|
|
|
|
|
|
name: page ? page.name : '',
|
|
|
|
|
|
number: page ? page.number : 0,
|
|
|
|
|
|
spots: spots.map(({ key, row, column }) => ({ key, row, column })),
|
2026-10-14 20:12:23 -04:00
|
|
|
|
};
|
2026-10-15 11:48:24 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buttonBody(spot) {
|
|
|
|
|
|
const b = spot.target ? buttons.get(spot.target) : null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
key: spot.key,
|
|
|
|
|
|
image: b ? b.image : null,
|
|
|
|
|
|
// A place with nothing on it is as answered as it will get.
|
|
|
|
|
|
seen: b ? b.seen : pages.loaded,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pageFrame = (viewer, page) => `event: page\ndata: ${JSON.stringify(pageBody(viewer.spots, page))}\n\n`;
|
|
|
|
|
|
const buttonFrame = (spot) => `event: button\ndata: ${JSON.stringify(buttonBody(spot))}\n\n`;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* What a stream would send on connect, for a tile that joined a stream
|
|
|
|
|
|
* already open on the page (a remount after a settings change): ProdDash
|
|
|
|
|
|
* reuses the connection, so the tile asks for this on `open` instead.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function snapshot(view) {
|
|
|
|
|
|
const page = pages.page(view.pageId);
|
|
|
|
|
|
const spots = spotsFor({ keys: view.keys }, page);
|
|
|
|
|
|
return { page: pageBody(spots, page), buttons: spots.map(buttonBody) };
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function write(viewer, chunk) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
viewer.res.write(chunk);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
drop(viewer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/** Bring a viewer up to date with the page book; send what changed. */
|
|
|
|
|
|
function refresh(viewer, force) {
|
|
|
|
|
|
const page = pages.page(viewer.pageId);
|
|
|
|
|
|
const next = spotsFor(viewer, page);
|
|
|
|
|
|
const before = new Map(viewer.spots.map((s) => [s.key, s.target]));
|
|
|
|
|
|
for (const s of next) if (s.target) watch(s.target).refs += 1;
|
|
|
|
|
|
for (const s of viewer.spots) {
|
|
|
|
|
|
if (!s.target) continue;
|
|
|
|
|
|
const b = buttons.get(s.target);
|
|
|
|
|
|
if (b) b.refs -= 1;
|
|
|
|
|
|
release(s.target);
|
|
|
|
|
|
}
|
|
|
|
|
|
viewer.spots = next;
|
|
|
|
|
|
const sig = JSON.stringify([status, page && page.name, page && page.number, next.map((s) => [s.key, s.target])]);
|
|
|
|
|
|
if (!force && sig === viewer.sig) return;
|
|
|
|
|
|
viewer.sig = sig;
|
|
|
|
|
|
write(viewer, pageFrame(viewer, page));
|
|
|
|
|
|
for (const s of next) if (force || before.get(s.key) !== s.target || !before.has(s.key)) write(viewer, buttonFrame(s));
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sendAll() {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
for (const v of viewers) refresh(v, false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sendButton(controlId) {
|
|
|
|
|
|
for (const v of viewers) for (const s of v.spots) if (s.target === controlId) write(v, buttonFrame(s));
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drop(viewer) {
|
|
|
|
|
|
if (!viewers.delete(viewer)) return;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
for (const s of viewer.spots) {
|
|
|
|
|
|
if (!s.target) continue;
|
|
|
|
|
|
const b = buttons.get(s.target);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
if (b) b.refs -= 1;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
release(s.target);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
function addViewer(res, view) {
|
|
|
|
|
|
const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' };
|
2026-10-14 20:12:23 -04:00
|
|
|
|
viewers.add(viewer);
|
2026-10-15 11:48:24 -04:00
|
|
|
|
refresh(viewer, true);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
return viewer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/* — the connection — */
|
|
|
|
|
|
|
2026-10-14 20:12:23 -04:00
|
|
|
|
if (host) {
|
|
|
|
|
|
link = createLink({
|
|
|
|
|
|
host,
|
|
|
|
|
|
port,
|
|
|
|
|
|
onStatus(next) {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
status = next.state === 'ok' && !pages.loaded ? { state: 'connecting', message: 'Connected — reading pages' } : next;
|
2026-10-14 20:12:23 -04:00
|
|
|
|
if (next.state !== 'ok') log(next.message);
|
|
|
|
|
|
sendAll();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
link.subscribe('pages.watch', undefined, (change) => {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const fresh = change && change.type === 'init';
|
2026-10-14 20:12:23 -04:00
|
|
|
|
pages.apply(change);
|
2026-10-15 11:48:24 -04:00
|
|
|
|
if (fresh) {
|
2026-10-14 20:12:23 -04:00
|
|
|
|
status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` };
|
|
|
|
|
|
log(`connected to Companion at ${host}:${port} (${pages.count} pages)`);
|
|
|
|
|
|
}
|
2026-10-15 11:48:24 -04:00
|
|
|
|
// A page renamed, a button added or moved, or a page re-imported changes what tiles show.
|
2026-10-14 20:12:23 -04:00
|
|
|
|
sendAll();
|
2026-10-15 11:48:24 -04:00
|
|
|
|
learnAll();
|
2026-10-14 20:12:23 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
heartbeat = setInterval(() => {
|
|
|
|
|
|
for (const v of viewers) write(v, ': ping\n\n');
|
|
|
|
|
|
}, HEARTBEAT_MS);
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
/** The + picker's entries: one per page that has a button worth showing. */
|
|
|
|
|
|
function tileList() {
|
|
|
|
|
|
return pages
|
|
|
|
|
|
.list()
|
|
|
|
|
|
.filter((p) => p.buttons.some((b) => !isNav(b.controlId)))
|
|
|
|
|
|
.map((p) => ({
|
|
|
|
|
|
id: p.id,
|
|
|
|
|
|
name: p.name,
|
|
|
|
|
|
description: `Companion page ${p.number}`,
|
|
|
|
|
|
settings: { page: p.id },
|
|
|
|
|
|
instanceSchema: {
|
|
|
|
|
|
layout: { type: 'switch', label: 'Page layout', default: true },
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 20:12:23 -04:00
|
|
|
|
current = {
|
|
|
|
|
|
get status() {
|
|
|
|
|
|
return status;
|
|
|
|
|
|
},
|
|
|
|
|
|
pages,
|
|
|
|
|
|
addViewer,
|
|
|
|
|
|
drop,
|
2026-10-15 11:48:24 -04:00
|
|
|
|
snapshot,
|
|
|
|
|
|
tileList,
|
2026-10-14 20:12:23 -04:00
|
|
|
|
};
|
|
|
|
|
|
const self = current;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
stop() {
|
|
|
|
|
|
clearInterval(heartbeat);
|
|
|
|
|
|
for (const v of viewers) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
v.res.end();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* gone */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
viewers.clear();
|
2026-10-15 11:48:24 -04:00
|
|
|
|
for (const b of buttons.values()) clearTimeout(b.linger);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
buttons.clear();
|
|
|
|
|
|
if (link) link.close();
|
|
|
|
|
|
if (current === self) current = null;
|
|
|
|
|
|
},
|
|
|
|
|
|
health() {
|
|
|
|
|
|
const shown = new Set();
|
2026-10-15 11:48:24 -04:00
|
|
|
|
for (const v of viewers) for (const s of v.spots) if (s.target) shown.add(s.target);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : '';
|
|
|
|
|
|
return { status: status.state, message: status.message + suffix };
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
tiles() {
|
|
|
|
|
|
return current ? current.tileList() : [];
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-14 20:12:23 -04:00
|
|
|
|
routes() {
|
|
|
|
|
|
return {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
'GET /page-options': (req, res) => {
|
2026-10-14 20:12:23 -04:00
|
|
|
|
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
|
2026-10-15 11:48:24 -04:00
|
|
|
|
return sendJson(res, 200, { options: current.pages.list().map((p) => ({ value: p.id, label: p.name })) });
|
2026-10-14 20:12:23 -04:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-10-15 11:48:24 -04:00
|
|
|
|
'GET /state/*': (req, res) => {
|
|
|
|
|
|
const view = viewFrom(req);
|
|
|
|
|
|
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
|
|
|
|
|
|
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
|
|
|
|
|
|
return sendJson(res, 200, current.snapshot(view));
|
2026-10-14 20:12:23 -04:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
'GET /stream/*': (req, res) => {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const view = viewFrom(req);
|
|
|
|
|
|
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
|
2026-10-14 20:12:23 -04:00
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
|
|
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
|
|
|
|
Connection: 'keep-alive',
|
|
|
|
|
|
'X-Accel-Buffering': 'no',
|
|
|
|
|
|
});
|
|
|
|
|
|
res.write('retry: 3000\n\n');
|
|
|
|
|
|
if (!current) {
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, spots: [] };
|
|
|
|
|
|
res.write(`event: page\ndata: ${JSON.stringify(body)}\n\n`);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
return void res.end();
|
|
|
|
|
|
}
|
|
|
|
|
|
const app = current;
|
2026-10-15 11:48:24 -04:00
|
|
|
|
const viewer = app.addViewer(res, view);
|
2026-10-14 20:12:23 -04:00
|
|
|
|
try {
|
|
|
|
|
|
req.socket.setKeepAlive(true, HEARTBEAT_MS);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* gone */
|
|
|
|
|
|
}
|
|
|
|
|
|
req.on('close', () => app.drop(viewer));
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|