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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-08-30 15:03:12 +00:00
parent 3ac70fc44d
commit a549bb065a
5 changed files with 861 additions and 0 deletions
+403
View File
@@ -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 = `
<div class="pt-wrap">
<div class="pt-bar">
<div class="pt-dd">
<button class="pt-btn pt-channels-btn" title="Choose visible channels">Channels ▾</button>
<div class="pt-menu" hidden></div>
</div>
<span class="pt-spacer"></span>
<button class="pt-btn pt-clear" title="Clear this tile (does not affect ProdCom)">Clear</button>
</div>
<div class="pt-stream">
<div class="pt-empty">Waiting for transcript…</div>
<div class="pt-entries"></div>
</div>
<button class="pt-jump" hidden>↓ New messages</button>
</div>`;
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 =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
'<circle cx="12" cy="8.5" r="3.2"/>' +
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
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();
},
};
}
+40
View File
@@ -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
}
}
}
+93
View File
@@ -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 || ''),
};
},
};
+194
View File
@@ -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; }
+131
View File
@@ -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:<port>. 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)`);
});