'use strict'; /** * A stand-in for Companion's tRPC websocket, so the module can be built and * tested without the real thing: * * node test/companion-mock.js 18000 * * then point Companion Buttons at host 127.0.0.1, port 18000 in /admin. * * It serves the three subscriptions the module uses — pages.watch, * preview.graphics.controlId and controls.watchControl — over a hand-rolled * websocket (no deps), with three pages: a status page with a page-up * button, a camera page, and an unnamed page holding only navigation. Page 1's first button flips between green and red every two * seconds, like a status button; page 1's second button redraws the same * picture every 300 ms, like a button fed a variable that has not changed. * Every 30 s it sends PING and drops a client that does not answer PONG, * the way Companion does. * * --rename-after rename page 2 after s seconds (an `update` change) * --move-after move the flipping button to page 2 after s seconds */ const http = require('http'); const crypto = require('crypto'); const zlib = require('zlib'); /* ── a solid-colour PNG, with a lighter band so pictures differ ─────── */ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(buf) { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([len, body, crc]); } function png([r, g, b], size = 72) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; // bit depth ihdr[9] = 2; // RGB const rows = []; for (let y = 0; y < size; y += 1) { const row = Buffer.alloc(1 + size * 3); const band = y > size * 0.4 && y < size * 0.6; for (let x = 0; x < size; x += 1) { row[1 + x * 3] = band ? 255 : r; row[2 + x * 3] = band ? 255 : g; row[3 + x * 3] = band ? 255 : b; } rows.push(row); } const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const out = Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0))]); return `data:image/png;base64,${out.toString('base64')}`; } /* ── the minimal websocket server (RFC 6455, text frames only) ────────── */ function frame(text) { const payload = Buffer.from(text, 'utf8'); let head; if (payload.length < 126) { head = Buffer.from([0x81, payload.length]); } else if (payload.length < 65536) { head = Buffer.alloc(4); head[0] = 0x81; head[1] = 126; head.writeUInt16BE(payload.length, 2); } else { head = Buffer.alloc(10); head[0] = 0x81; head[1] = 127; head.writeBigUInt64BE(BigInt(payload.length), 2); } return Buffer.concat([head, payload]); } function readFrames(state, data, onText, onClose) { state.buf = Buffer.concat([state.buf, data]); for (;;) { const b = state.buf; if (b.length < 2) return; const op = b[0] & 0x0f; let len = b[1] & 0x7f; let off = 2; if (len === 126) { if (b.length < 4) return; len = b.readUInt16BE(2); off = 4; } else if (len === 127) { if (b.length < 10) return; len = Number(b.readBigUInt64BE(2)); off = 10; } const masked = (b[1] & 0x80) !== 0; const mask = masked ? b.subarray(off, off + 4) : null; if (masked) off += 4; if (b.length < off + len) return; const payload = Buffer.from(b.subarray(off, off + len)); if (mask) for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4]; state.buf = b.subarray(off + len); if (op === 0x8) return onClose(); if (op === 0x1) onText(payload.toString('utf8')); } } /* ── Companion's side ─────────────────────────────────────────────────── */ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) { const pages = { p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' }, 1: { 0: 'bank:up1' } } }, p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } }, p3: { id: 'p3', name: 'PAGE', controls: { 0: { 0: 'bank:up3' } } }, }; const layered = (value, isExpression = false) => ({ type: 'button-layered', style: { layers: [{ id: 'canvas', type: 'canvas' }, { id: 'text0', type: 'text', text: { value, isExpression } }] }, }); const configs = { 'bank:flip': layered('FLIP'), 'bank:steady': layered('Steady'), 'bank:blue': layered('Blue'), 'bank:cam1': { type: 'button', style: { text: 'CAM 1' } }, 'bank:cam2': layered('CAM 2'), 'bank:up1': { type: 'pageup', style: {} }, 'bank:up3': { type: 'pageup', style: {} }, }; const order = ['p1', 'p2', 'p3']; const looks = { 'bank:steady': png([200, 140, 0]), 'bank:blue': png([20, 60, 200]), 'bank:cam1': png([90, 90, 90]), 'bank:cam2': png([120, 0, 120]), }; const GREEN = png([0, 160, 60]); const RED = png([200, 20, 20]); let flipRed = false; looks['bank:flip'] = GREEN; const clients = new Set(); const stats = { connections: 0, pongs: 0, subscriptions: 0, stops: 0 }; function send(client, msg) { if (client.closed) return; try { client.socket.write(frame(typeof msg === 'string' ? msg : JSON.stringify(msg))); } catch { /* gone */ } } function pageUpdate(change) { for (const c of clients) for (const [id, sub] of c.subs) if (sub.path === 'pages.watch') send(c, { id, result: { type: 'data', data: change } }); } function redraw(controlId) { for (const c of clients) { for (const [id, sub] of c.subs) { if (sub.path === 'preview.graphics.controlId' && sub.input.controlId === controlId) { send(c, { id, result: { type: 'data', data: looks[controlId] || null } }); } } } } function onMessage(client, text) { if (text === 'PONG') { client.awaitingPong = false; stats.pongs += 1; return; } let msg; try { msg = JSON.parse(text); } catch { return; } if (msg.method === 'subscription.stop') { client.subs.delete(msg.id); stats.stops += 1; return send(client, { id: msg.id, result: { type: 'stopped' } }); } if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } }); const { path, input } = msg.params || {}; if (path === 'controls.watchControl') { const cfg = configs[input && input.controlId]; if (!cfg) return send(client, { id: msg.id, error: { message: `Control ${input && input.controlId} not found` } }); stats.subscriptions += 1; client.subs.set(msg.id, { path, input }); send(client, { id: msg.id, result: { type: 'started' } }); return send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', config: cfg, runtime: {} } } }); } if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') { return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } }); } stats.subscriptions += 1; client.subs.set(msg.id, { path, input }); send(client, { id: msg.id, result: { type: 'started' } }); if (path === 'pages.watch') { send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', order: [...order], pages: JSON.parse(JSON.stringify(pages)) } } }); } else { send(client, { id: msg.id, result: { type: 'data', data: looks[input.controlId] || null } }); } } const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('companion mock\n'); }); server.on('upgrade', (req, socket) => { if (new URL(req.url, 'http://x').pathname !== '/trpc') { socket.end('HTTP/1.1 404 Not Found\r\n\r\n'); return; } const accept = crypto.createHash('sha1').update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64'); socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); const client = { socket, subs: new Map(), state: { buf: Buffer.alloc(0) }, closed: false, awaitingPong: false }; clients.add(client); stats.connections += 1; log(`client connected (${clients.size})`); const close = () => { if (client.closed) return; client.closed = true; clients.delete(client); socket.destroy(); log(`client gone (${clients.size})`); }; socket.on('data', (d) => readFrames(client.state, d, (t) => onMessage(client, t), close)); socket.on('close', close); socket.on('error', close); }); const timers = [ setInterval(() => { flipRed = !flipRed; looks['bank:flip'] = flipRed ? RED : GREEN; redraw('bank:flip'); }, 2000), setInterval(() => redraw('bank:steady'), 300), setInterval(() => { for (const c of clients) { if (c.awaitingPong) { log('no PONG — dropping client'); c.socket.destroy(); continue; } c.awaitingPong = true; send(c, 'PING'); } }, 30000), ]; if (renameAfter) { timers.push(setTimeout(() => { pages.p2.name = 'Cameras Renamed'; pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [] }] }); }, renameAfter * 1000)); } if (moveAfter) { timers.push(setTimeout(() => { delete pages.p1.controls[0][0]; pages.p2.controls[1][0] = 'bank:flip'; pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [ { id: 'p1', name: null, controls: [{ row: 0, column: 0, controlId: null }] }, { id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:flip' }] }, ], }); }, moveAfter * 1000)); } return new Promise((resolve) => { server.listen(port, '127.0.0.1', () => { resolve({ port: server.address().port, stats, clients, pageUpdate, redraw, looks, pages, configs, /** Drop every client without a close frame, as a crash or a cable pull would. */ dropAll() { for (const c of clients) c.socket.destroy(); }, close() { for (const t of timers) clearInterval(t); for (const c of clients) c.socket.destroy(); return new Promise((r) => server.close(() => r())); }, }); }); }); } module.exports = { startMock, png }; if (require.main === module) { const args = process.argv.slice(2); const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? Number(args[i + 1]) || 0 : 0; }; const port = Number(args.find((a) => /^\d+$/.test(a))) || 18000; startMock(port, { renameAfter: opt('--rename-after'), moveAfter: opt('--move-after'), log: (m) => console.log(`[companion-mock] ${m}`) }).then((m) => { console.log(`[companion-mock] listening on 127.0.0.1:${m.port} — point Companion Buttons at 127.0.0.1 port ${m.port}`); }); }