From a549bb065ad0015de6c16856816788828a8d386a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:03:12 +0000 Subject: [PATCH] prodcom-transcript module: ProdCom proxy + live transcript tile Milestone 3. Server part proxies /prodcom/* (SSE passthrough included) to the admin-configured ProdCom URL, adding the Bearer key server-side so it never reaches browsers; reports proxy health for the admin page. Client ports the prodcom-listener UI into the module contract: history load, live SSE with in-place updates of in-progress speech, channel colors, channel visibility toggles + group presets (persisted per tile), hard channel filter / text size / timestamps as instance settings, auto-scroll with a jump-to-latest pill. tools/prodcom-mock.js simulates ProdCom (word-by-word in-progress speech) for testing; verified against it, including kill-and-restart recovery. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH --- modules/prodcom-transcript/client.js | 403 +++++++++++++++++++++++++ modules/prodcom-transcript/module.json | 40 +++ modules/prodcom-transcript/server.js | 93 ++++++ modules/prodcom-transcript/style.css | 194 ++++++++++++ tools/prodcom-mock.js | 131 ++++++++ 5 files changed, 861 insertions(+) create mode 100644 modules/prodcom-transcript/client.js create mode 100644 modules/prodcom-transcript/module.json create mode 100644 modules/prodcom-transcript/server.js create mode 100644 modules/prodcom-transcript/style.css create mode 100644 tools/prodcom-mock.js diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js new file mode 100644 index 0000000..8d85ca2 --- /dev/null +++ b/modules/prodcom-transcript/client.js @@ -0,0 +1,403 @@ +/* ProdCom Transcript — client part. + + Ported from prodcom-listener/public/app.js into the ProdDash module + contract: everything lives inside the tile's root, per-tile state + (hidden channels, timestamps, text size) persists in instance settings, + and all network traffic goes through moduleApi (the module's server + proxy at /prodcom/*). */ + +export default function create({ root, moduleApi }) { + /* ── per-instance state ─────────────────────────────────────────── */ + + const channels = new Map(); // channelId -> {name, color} + const groups = new Map(); // groupId -> {name, channelIds} + const entryEls = new Map(); // entryId -> element + let pinnedToBottom = true; + let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); + let stream = null; // moduleApi.sse handle + let bootTimer = null; + let stopped = false; + + /* ── DOM skeleton ───────────────────────────────────────────────── */ + + root.innerHTML = ` +
+
+
+ + +
+ + +
+
+
Waiting for transcript…
+
+
+ +
`; + + const wrap = root.querySelector('.pt-wrap'); + const streamEl = root.querySelector('.pt-stream'); + const entriesEl = root.querySelector('.pt-entries'); + const emptyEl = root.querySelector('.pt-empty'); + const jumpBtn = root.querySelector('.pt-jump'); + const channelBtn = root.querySelector('.pt-channels-btn'); + const channelMenu = root.querySelector('.pt-menu'); + + function applyInstanceSettings() { + const s = moduleApi.instanceSettings; + const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); + wrap.style.setProperty('--pt-size', size + 'px'); + wrap.classList.toggle('show-times', Boolean(s.showTimes)); + } + applyInstanceSettings(); + + /* ── channel filter (instance setting) ──────────────────────────── */ + + function channelFilter() { + return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase(); + } + + /** Entry hidden by the hard per-tile filter (settings.channel)? */ + function filteredOut(channelId, channelName) { + const want = channelFilter(); + if (!want) return false; + const known = channels.get(channelId); + const name = ((known && known.name) || channelName || '').toLowerCase(); + return name !== want && String(channelId).toLowerCase() !== want; + } + + /* ── channel visibility toggles ─────────────────────────────────── */ + + function persistHidden() { + moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] }); + } + + function applyFilter(el) { + const hidden = hiddenChannels.has(el.dataset.channelId) + || filteredOut(el.dataset.channelId, el.dataset.channelName); + el.classList.toggle('hidden-by-filter', hidden); + } + + function toggleChannel(id) { + if (hiddenChannels.has(id)) hiddenChannels.delete(id); + else hiddenChannels.add(id); + persistHidden(); + rebuildChannelMenu(); + entryEls.forEach((el) => applyFilter(el)); + if (pinnedToBottom) scrollToBottom(); + } + + /** Show exactly this group's channels, hide the rest */ + function applyGroup(g) { + hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid))); + persistHidden(); + rebuildChannelMenu(); + entryEls.forEach((el) => applyFilter(el)); + if (pinnedToBottom) scrollToBottom(); + } + + function groupIsActive(g) { + const members = g.channelIds.filter((cid) => channels.has(cid)); + if (!members.length) return false; + return [...channels.keys()].every((cid) => + members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid) + ); + } + + function rebuildChannelMenu() { + channelMenu.innerHTML = ''; + + if (groups.size) { + for (const g of groups.values()) { + const on = groupIsActive(g); + const item = document.createElement('button'); + item.className = 'pt-channel-item pt-group-item' + (on ? ' on' : ''); + item.setAttribute('role', 'menuitemradio'); + item.setAttribute('aria-checked', String(on)); + const check = document.createElement('span'); + check.className = 'check'; + check.textContent = '✓'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = g.name; + item.append(check, name); + item.addEventListener('click', (e) => { + e.stopPropagation(); + applyGroup(g); + }); + channelMenu.appendChild(item); + } + const divider = document.createElement('div'); + divider.className = 'pt-menu-divider'; + channelMenu.appendChild(divider); + } + + let visibleCount = 0; + for (const [id, info] of channels) { + const on = !hiddenChannels.has(id); + if (on) visibleCount++; + const item = document.createElement('button'); + item.className = 'pt-channel-item' + (on ? ' on' : ''); + item.setAttribute('role', 'menuitemcheckbox'); + item.setAttribute('aria-checked', String(on)); + const check = document.createElement('span'); + check.className = 'check'; + check.textContent = '✓'; + const name = document.createElement('span'); + name.className = 'name'; + name.textContent = info.name; + name.style.color = info.color; + item.append(check, name); + item.addEventListener('click', (e) => { + // keep the menu open: the rebuild detaches this item, which would + // make the outside-click handler think we clicked outside + e.stopPropagation(); + toggleChannel(id); + }); + channelMenu.appendChild(item); + } + channelBtn.textContent = visibleCount === channels.size + ? 'Channels ▾' + : `Channels (${visibleCount}/${channels.size}) ▾`; + } + + channelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + channelMenu.hidden = !channelMenu.hidden; + }); + const onDocClick = (e) => { + if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true; + }; + document.addEventListener('click', onDocClick); + + /* ── clear (local view only) ────────────────────────────────────── */ + + root.querySelector('.pt-clear').addEventListener('click', () => { + entryEls.forEach((el) => el.remove()); + entryEls.clear(); + emptyEl.hidden = false; + }); + + /* ── scrolling ──────────────────────────────────────────────────── */ + + streamEl.addEventListener('scroll', () => { + const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; + pinnedToBottom = nearBottom; + if (nearBottom) jumpBtn.hidden = true; + }); + + jumpBtn.addEventListener('click', () => { + pinnedToBottom = true; + jumpBtn.hidden = true; + scrollToBottom(); + }); + + function scrollToBottom() { + streamEl.scrollTop = streamEl.scrollHeight; + } + + function afterInsert(el) { + if (el.classList.contains('hidden-by-filter')) return; + if (pinnedToBottom) scrollToBottom(); + else jumpBtn.hidden = false; + } + + /* ── entry rendering ────────────────────────────────────────────── */ + + function channelInfo(entry) { + const known = channels.get(entry.channelId); + return { + name: (known && known.name) || entry.channelName || 'Unknown', + color: (known && known.color) || 'var(--muted)', + }; + } + + function fmtTime(iso) { + const d = iso ? new Date(iso) : new Date(); + return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false }); + } + + function upsertEntry(entry) { + if (!entry || typeof entry.text !== 'string') return; + const id = entry.id || entry.channelId + '|' + entry.date; + + let el = entryEls.get(id); + if (!el) { + emptyEl.hidden = true; + el = document.createElement('div'); + el.className = 'pt-entry'; + el.dataset.channelId = entry.channelId || ''; + el.dataset.channelName = entry.channelName || ''; + const info = channelInfo(entry); + el.style.setProperty('--ch', info.color); + + const time = document.createElement('span'); + time.className = 'time'; + time.textContent = fmtTime(entry.date); + + const avatar = document.createElement('span'); + avatar.className = 'avatar'; + avatar.style.color = info.color; + avatar.innerHTML = + ''; + + const ch = document.createElement('span'); + ch.className = 'channel'; + ch.textContent = info.name; + ch.style.color = info.color; + + const text = document.createElement('span'); + text.className = 'text'; + + const body = document.createElement('div'); + body.className = 'body'; + body.append(ch, text); + + el.append(time, avatar, body); + entryEls.set(id, el); + applyFilter(el); + entriesEl.appendChild(el); + updateEntryEl(el, entry); + afterInsert(el); + } else { + updateEntryEl(el, entry); + if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom(); + } + } + + function updateEntryEl(el, entry) { + el.querySelector('.text').textContent = entry.translatedText || entry.text; + el.classList.toggle('in-progress', entry.inProgress === true); + } + + /* ── SSE event parsing ──────────────────────────────────────────── */ + + /* The stream sends JSON objects for new/updated/completed transcript + entries. Be liberal about the envelope shape. */ + function extractEntries(obj) { + if (!obj || typeof obj !== 'object') return []; + if (Array.isArray(obj)) return obj.flatMap(extractEntries); + if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj]; + for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) { + if (obj[key] && typeof obj[key] === 'object') { + const found = extractEntries(obj[key]); + if (found.length) return found; + } + } + return []; + } + + function handleEventData(raw) { + let obj; + try { obj = JSON.parse(raw); } catch { return; } + const kind = String(obj.type || obj.event || '').toLowerCase(); + if (kind.includes('clear')) { + entryEls.forEach((el) => el.remove()); + entryEls.clear(); + emptyEl.hidden = false; + return; + } + extractEntries(obj).forEach(upsertEntry); + } + + /* ── data loading (all via the module's proxy) ──────────────────── */ + + async function loadChannels() { + const res = await moduleApi.fetch('/prodcom/api/v1/channels'); + if (!res.ok) throw new Error('channels ' + res.status); + const body = await res.json(); + channels.clear(); + for (const ch of body.data || []) { + channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' }); + } + rebuildChannelMenu(); + } + + async function loadGroups() { + const res = await moduleApi.fetch('/prodcom/api/v1/groups'); + if (!res.ok) throw new Error('groups ' + res.status); + const body = await res.json(); + groups.clear(); + for (const g of body.data || []) { + groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] }); + } + rebuildChannelMenu(); + } + + async function loadHistory() { + const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100'); + if (!res.ok) throw new Error('transcript ' + res.status); + const body = await res.json(); + const entries = (body.data || []).slice(); + entries.sort((a, b) => new Date(a.date) - new Date(b.date)); + entries.forEach(upsertEntry); + } + + /* ── live stream ────────────────────────────────────────────────── */ + + const SSE_EVENT_NAMES = [ + 'transcript', 'transcript.new', 'transcript.updated', 'transcript.update', + 'transcript.completed', 'transcript.complete', 'entry', 'new', 'update', + 'updated', 'complete', 'completed', + ]; + + function connect() { + const onEvent = (ev) => handleEventData(ev.data); + const events = {}; + for (const name of SSE_EVENT_NAMES) events[name] = onEvent; + + stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', { + open() { + moduleApi.setStatus('ok', 'Live'); + // refresh channels/groups and backfill entries missed while + // disconnected (upsertEntry dedupes by id, so this is harmless) + loadChannels().catch(() => {}); + loadGroups().catch(() => {}); + loadHistory().catch(() => {}); + }, + error() { + moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…'); + }, + message: onEvent, + events, + }); + } + + /* ── lifecycle ──────────────────────────────────────────────────── */ + + async function boot() { + if (stopped) return; + moduleApi.setStatus('connecting', 'Connecting to ProdCom…'); + try { + await loadChannels(); + await loadGroups().catch(() => {}); // groups are optional + await loadHistory(); + } catch { + moduleApi.setStatus('error', 'ProdCom unreachable — retrying…'); + bootTimer = setTimeout(boot, 5000); + return; + } + scrollToBottom(); + connect(); + } + + return { + start() { + boot(); + }, + stop() { + stopped = true; + clearTimeout(bootTimer); + stream?.close(); + document.removeEventListener('click', onDocClick); + root.innerHTML = ''; + }, + onResize() { + if (pinnedToBottom) scrollToBottom(); + }, + }; +} diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json new file mode 100644 index 0000000..8e05663 --- /dev/null +++ b/modules/prodcom-transcript/module.json @@ -0,0 +1,40 @@ +{ + "id": "prodcom-transcript", + "name": "ProdCom Transcript", + "version": "1.0.0", + "description": "Live ProdCom transcript stream", + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 3, "h": 2 }, + "defaultSize": { "w": 4, "h": 4 }, + "configSchema": { + "prodcomUrl": { + "type": "string", + "label": "ProdCom URL", + "default": "http://10.3.11.152:24480" + }, + "apiKey": { + "type": "password", + "label": "API key (only if PSK auth is enabled in ProdCom)", + "default": "" + } + }, + "instanceSchema": { + "channel": { + "type": "string", + "label": "Channel filter (channel name; empty = all)", + "default": "" + }, + "textSize": { + "type": "number", + "label": "Text size (px)", + "default": 15 + }, + "showTimes": { + "type": "boolean", + "label": "Show timestamps", + "default": false + } + } +} diff --git a/modules/prodcom-transcript/server.js b/modules/prodcom-transcript/server.js new file mode 100644 index 0000000..13fa59f --- /dev/null +++ b/modules/prodcom-transcript/server.js @@ -0,0 +1,93 @@ +'use strict'; + +/** + * ProdCom Transcript — server part. + * + * One wildcard route that proxies /prodcom/* (including the SSE transcript + * stream) to the ProdCom API configured in the admin page, so browsers never + * talk to ProdCom directly (no CORS, and the API key never reaches clients). + * Ported from prodcom-listener/server.js. + */ + +const http = require('http'); + +// Hop-by-hop headers that must not be forwarded +const HOP_HEADERS = new Set([ + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade', +]); + +/** Health shown in the admin page: outcome of the most recent proxied request. */ +let health = { status: 'connecting', message: 'No traffic yet' }; + +function proxy(req, res, target, apiKey) { + const upstreamPath = (req.wildcard || '/') + (req.search || ''); + const headers = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v; + } + headers.host = target.host; + delete headers['accept-encoding']; // keep SSE unbuffered and simple + if (apiKey) headers.authorization = 'Bearer ' + apiKey; + + const preq = http.request( + { + hostname: target.hostname, + port: target.port || 80, + path: upstreamPath, + method: req.method, + headers, + }, + (pres) => { + health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` }; + const outHeaders = {}; + for (const [k, v] of Object.entries(pres.headers)) { + if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v; + } + res.writeHead(pres.statusCode, outHeaders); + // Flush immediately — SSE streams may send no bytes for a while, and + // the browser needs the headers to consider the stream open. + res.flushHeaders(); + pres.pipe(res); + // If ProdCom dies mid-stream, pipe() unpipes on error without ending + // the browser's response, leaving SSE clients hanging on a dead + // stream. Destroy it so they notice immediately and reconnect. + pres.on('close', () => { + if (!res.writableEnded) res.destroy(); + }); + } + ); + + preq.on('error', (e) => { + health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) }; + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + } + res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) })); + }); + + // Tear down the upstream connection when the browser disconnects + res.on('close', () => preq.destroy()); + req.pipe(preq); +} + +module.exports = { + init({ config }) { + health = config.prodcomUrl + ? { status: 'connecting', message: 'No traffic yet' } + : { status: 'error', message: 'No ProdCom URL configured' }; + return { + stop() { /* nothing persistent to tear down — streams die with their sockets */ }, + health: () => health, + }; + }, + + routes({ config, log }) { + // An invalid URL throws here, which surfaces as a mount error in /admin. + const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480'); + log(`proxying /prodcom/* → ${target.origin}`); + return { + '* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''), + }; + }, +}; diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css new file mode 100644 index 0000000..93dfd9e --- /dev/null +++ b/modules/prodcom-transcript/style.css @@ -0,0 +1,194 @@ +/* ProdCom Transcript module — scoped to its tiles, shell variables only. + Entry styling carried over from prodcom-listener/public/style.css. */ + +[data-module="prodcom-transcript"] .pt-wrap { + --pt-size: 15px; + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +/* ── toolbar ── */ + +[data-module="prodcom-transcript"] .pt-bar { + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border-bottom: 1px solid var(--border); +} + +[data-module="prodcom-transcript"] .pt-spacer { flex: 1; } + +[data-module="prodcom-transcript"] .pt-btn { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 8px; + font-size: 11px; + font-family: inherit; + cursor: pointer; + white-space: nowrap; +} +[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); } + +[data-module="prodcom-transcript"] .pt-dd { position: relative; } + +[data-module="prodcom-transcript"] .pt-menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 30; + min-width: 170px; + max-height: 45vh; + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 5px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} +[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; } + +[data-module="prodcom-transcript"] .pt-channel-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 9px; + background: none; + border: none; + border-radius: 6px; + color: var(--text); + font-size: 12px; + font-family: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} +[data-module="prodcom-transcript"] .pt-channel-item:hover { background: var(--border); } + +[data-module="prodcom-transcript"] .pt-channel-item .check { + width: 14px; + flex: none; + color: var(--accent); + font-weight: 700; + visibility: hidden; +} +[data-module="prodcom-transcript"] .pt-channel-item.on .check { visibility: visible; } +[data-module="prodcom-transcript"] .pt-channel-item .name { font-weight: 600; } + +[data-module="prodcom-transcript"] .pt-menu-divider { + height: 1px; + background: var(--border); + margin: 5px 4px; +} + +/* ── stream ── */ + +[data-module="prodcom-transcript"] .pt-stream { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 10px 12px; + overscroll-behavior: contain; +} + +[data-module="prodcom-transcript"] .pt-empty { + color: var(--muted); + font-size: 13px; + text-align: center; + padding-top: 14%; +} +[data-module="prodcom-transcript"] .pt-empty[hidden] { display: none; } + +[data-module="prodcom-transcript"] .pt-entry { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 5px 6px; + border-radius: 8px; + font-size: var(--pt-size); + line-height: 1.45; + animation: pt-fade-in 0.25s ease-out; +} +[data-module="prodcom-transcript"] .pt-entry:hover { background: var(--bg); } + +@keyframes pt-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} + +[data-module="prodcom-transcript"] .pt-entry .time { + display: none; + color: var(--muted); + font-size: 0.72em; + font-variant-numeric: tabular-nums; + flex: none; + padding-top: 3px; +} +[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; } + +[data-module="prodcom-transcript"] .pt-entry .avatar { + flex: none; + width: calc(var(--pt-size) * 1.9); + height: calc(var(--pt-size) * 1.9); + border-radius: 26%; + display: flex; + align-items: center; + justify-content: center; + /* ~17% alpha tint of the channel color */ + background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent); +} +[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; } + +[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; } + +[data-module="prodcom-transcript"] .pt-entry .channel { + display: block; + font-size: 0.82em; + font-weight: 700; + letter-spacing: 0.01em; + line-height: 1; + white-space: nowrap; + margin-bottom: 4px; +} + +[data-module="prodcom-transcript"] .pt-entry .text { + display: block; + overflow-wrap: break-word; +} + +[data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); } +[data-module="prodcom-transcript"] .pt-entry.in-progress .text::after { + content: "▍"; + color: var(--ch, var(--accent)); + animation: pt-blink 1s steps(2) infinite; +} +@keyframes pt-blink { 50% { opacity: 0; } } + +[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; } + +/* ── jump-to-latest ── */ + +[data-module="prodcom-transcript"] .pt-jump { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 6; + background: var(--accent); + color: var(--bg); + font-weight: 600; + font-size: 12px; + font-family: inherit; + border: none; + border-radius: 999px; + padding: 7px 14px; + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); +} +[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; } diff --git a/tools/prodcom-mock.js b/tools/prodcom-mock.js new file mode 100644 index 0000000..04df8b8 --- /dev/null +++ b/tools/prodcom-mock.js @@ -0,0 +1,131 @@ +/** + * Mock ProdCom API for verifying the prodcom-transcript module without a + * live ProdCom. Serves the endpoints the module uses — channels, groups, + * transcript history and the SSE stream — and simulates live speech: + * entries appear in progress, grow word by word (same id, in-place update), + * then complete. + * + * node tools/prodcom-mock.js [port] (default 24480) + * + * Point the module's admin config at http://127.0.0.1:. Kill and + * restart this process while tiles are open to watch them degrade and + * recover on their own. + */ +'use strict'; + +const http = require('http'); + +const PORT = Number.parseInt(process.argv[2] || '24480', 10); +const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10); + +const CHANNELS = [ + { id: 'ch-stage', name: 'Stage', color: '#e0645c' }, + { id: 'ch-foh', name: 'FOH', color: '#5c9de0' }, + { id: 'ch-video', name: 'Video', color: '#5cd08c' }, + { id: 'ch-light', name: 'Lighting', color: '#e0b45c' }, +]; + +const GROUPS = [ + { id: 'g-band', name: 'Band', channelIds: ['ch-stage', 'ch-foh'] }, + { id: 'g-tech', name: 'Tech', channelIds: ['ch-video', 'ch-light'] }, +]; + +const PHRASES = [ + 'Standby for the walk-in loop.', + 'Vocals are hot on channel two, trim it down a touch please.', + 'Camera three, give me a slow push on the worship leader.', + 'House to half, stage wash up for the next song.', + 'That was the last chorus, transition cue is next.', + 'Confidence monitor is frozen, refreshing it now.', + 'Great job everyone, that transition was clean.', + 'Pastor is walking up in thirty seconds.', +]; + +let history = []; // completed + in-progress entries, oldest first +// Unique across mock restarts, so a reconnecting client's dedupe-by-id +// treats post-restart entries as new (like real ProdCom ids would be). +const RUN = Date.now().toString(36); +let nextId = 1; +const streams = new Set(); + +/** The one in-progress utterance, or null. */ +let speaking = null; // { entry, words, at } + +function broadcast(entry) { + const frame = 'data: ' + JSON.stringify({ type: entry.inProgress ? 'transcript.update' : 'transcript.completed', entry }) + '\n\n'; + for (const res of streams) { + try { res.write(frame); } catch { streams.delete(res); } + } +} + +function stepSimulation() { + if (!speaking) { + const channel = CHANNELS[Math.floor(Math.random() * CHANNELS.length)]; + const words = PHRASES[Math.floor(Math.random() * PHRASES.length)].split(' '); + const entry = { + id: 'e' + RUN + '-' + nextId++, + channelId: channel.id, + channelName: channel.name, + text: words[0], + date: new Date().toISOString(), + inProgress: true, + }; + speaking = { entry, words, at: 1 }; + history.push(entry); + if (history.length > 200) history.shift(); + broadcast(entry); + return; + } + const { entry, words } = speaking; + if (speaking.at < words.length) { + entry.text = words.slice(0, ++speaking.at).join(' '); + broadcast(entry); + } else { + entry.inProgress = false; + broadcast(entry); + console.log(`[prodcom-mock] ${entry.channelName}: ${entry.text}`); + speaking = null; + } +} + +setInterval(stepSimulation, STEP_MS); + +function sendJson(res, body) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://x.invalid`); + const p = url.pathname; + + if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS }); + if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS }); + if (p === '/api/v1/transcript') { + const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10) || 100; + return sendJson(res, { data: history.slice(-limit) }); + } + if (p === '/api/v1/transcript/stream') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + res.write('retry: 3000\n\n'); + streams.add(res); + console.log(`[prodcom-mock] stream client connected (${streams.size})`); + res.on('close', () => { + streams.delete(res); + console.log(`[prodcom-mock] stream client left (${streams.size})`); + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +}); + +server.requestTimeout = 0; +server.listen(PORT, '127.0.0.1', () => { + console.log(`[prodcom-mock] ProdCom mock API on http://127.0.0.1:${PORT} (step ${STEP_MS}ms)`); +});