2026-08-30 15:03:12 +00:00
'use strict' ;
/**
* ProdCom Transcript — server part.
*
2026-09-15 12:43:08 -04:00
* One connection to ProdCom for the whole server, however many dashboards
* show the transcript. The module holds the channel list, the groups and
* the last entries, follows ProdCom's SSE stream, and hands every browser a
* snapshot on connect plus each change as it happens. (It used to proxy each
* browser's stream to ProdCom one to one — N screens meant N upstream
* connections; ProdCom now sees one.)
*
* Routes (under /api/modules/prodcom-transcript):
2026-10-05 13:55:55 -04:00
* GET /state → { status, channels, groups, entries } (entries by date)
* GET /stream → SSE: `state` (the snapshot, first), then `entry` for each
* changed entry, `clear`, and `status` when the link changes
* GET /messaging → what the Send Message module may send as: every
* channel that accepts typed text (typingEnabled), as
* an identity — typed text goes out *as* that channel
* (the module guide's contract)
* POST /messaging/send → { destination, text } with the Send Message unlock
* as a Bearer token — checked with that module over
* loopback, then POST /api/v1/channels/{id}/send, so a
* dashboard's typed text goes out on a ProdCom channel
* over this one connection. This module has no PIN and
* no send tile of its own.
2026-09-15 12:43:08 -04:00
*
* ProdCom's API, as the reference listener speaks it:
* GET /api/v1/channels, /api/v1/groups, /api/v1/transcript?limit=N and the
* SSE /api/v1/transcript/stream, whose frames carry an entry, or a
* { type, entry | data } wrapper, in a few historical shapes (see
2026-10-05 13:55:55 -04:00
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
* text on a channel (it comes back on the stream with source "typed"). A
* bearer API key is sent when one is configured.
2026-08-30 15:03:12 +00:00
*/
const http = require ( 'http' );
2026-10-05 13:55:55 -04:00
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
const SENDER_ID = 'send-message' ;
const MAX_TEXT_CHARS = 2000 ;
const BODY_LIMIT_BYTES = 64 * 1024 ;
2026-09-15 12:43:08 -04:00
const HISTORY_LIMIT = 100 ; // entries asked for on (re)connect
const ENTRIES_MAX = 300 ; // entries kept in memory
const RETRY_MIN_MS = 2000 ;
const RETRY_MAX_MS = 15000 ;
const REQUEST_TIMEOUT_MS = 8000 ;
const REFRESH_LISTS_MS = 5 * 60 * 1000 ; // channels and groups re-read
const HEARTBEAT_MS = 15000 ;
2026-08-30 15:03:12 +00:00
2026-08-31 19:33:08 +00:00
/** The ProdCom base URL from the endpoint config. A legacy prodcomUrl (saved
before the endpoint field existed) can only still be present until the
admin form is re-saved, so when it exists it wins over the endpoint's
schema default. */
function resolveTarget ( config ) {
if ( config . prodcomUrl ) return new URL ( config . prodcomUrl );
const ep = config . prodcom ;
if ( ep && ep . host && Number ( ep . port )) return new URL ( `http:// ${ ep . host } : ${ Number ( ep . port ) } ` );
return null ;
}
2026-09-15 12:43:08 -04:00
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
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 [];
}
/** One entry as the tiles read it; an entry without an id is keyed by channel and time. */
function normalizeEntry ( e ) {
const id = e . id !== undefined && e . id !== null && e . id !== '' ? String ( e . id ) : ` ${ e . channelId || '' } | ${ e . date || '' } ` ;
return {
id ,
channelId : e . channelId === undefined || e . channelId === null ? '' : String ( e . channelId ),
channelName : e . channelName ? String ( e . channelName ) : '' ,
text : String ( e . text ?? '' ),
date : e . date ? String ( e . date ) : new Date (). toISOString (),
inProgress : Boolean ( e . inProgress ),
2026-10-05 13:55:55 -04:00
// audio (speech), typed (a dashboard or ProdCom's own box), automation
source : e . source === 'typed' || e . source === 'automation' ? String ( e . source ) : 'audio' ,
2026-09-15 12:43:08 -04:00
};
}
2026-10-05 13:55:55 -04:00
/* ── small helpers ──────────────────────────────────────────────────── */
function sendJson ( res , code , body ) {
res . writeHead ( code , { 'Content-Type' : 'application/json; charset=utf-8' , 'Cache-Control' : 'no-store' });
res . end ( JSON . stringify ( body ));
}
function readJson ( req ) {
return new Promise (( resolve , reject ) => {
let data = '' ;
let size = 0 ;
req . on ( 'data' , ( chunk ) => {
size += chunk . length ;
if ( size > BODY_LIMIT_BYTES ) {
reject ( new Error ( 'Request body too large.' ));
req . destroy ();
return ;
}
data += chunk ;
});
req . on ( 'end' , () => {
if ( ! data . trim ()) return resolve ({});
try {
const body = JSON . parse ( data );
resolve ( body && typeof body === 'object' ? body : {});
} catch {
reject ( new Error ( 'Malformed JSON.' ));
}
});
req . on ( 'error' , reject );
});
}
/** Route handlers are async — the shell only catches synchronous throws. */
function guard ( fn ) {
return ( req , res ) => {
Promise . resolve ()
. then (() => fn ( req , res ))
. catch (( err ) => {
if ( ! res . headersSent ) sendJson ( res , 500 , { error : err ? . message || String ( err ) });
else try { res . end (); } catch { /* gone */ }
});
};
}
function bearer ( req ) {
const m = /^Bearer\s+([A-Za-z0-9]+)\s*$/ . exec ( String ( req . headers . authorization || '' ));
return m ? m [ 1 ] : '' ;
}
/**
* Is the unlock token on this request good? Only the Send Message module
* knows — it owns the PIN — so ask it over loopback. Answers { ok: true,
* expiresAt } or { ok: false, status, error, code } ready to send back.
*/
async function verifyUnlock ( req , shellPort ) {
const token = bearer ( req );
if ( ! token ) return { ok : false , status : 401 , error : 'Locked — enter the PIN' , code : 'locked' };
if ( ! shellPort ) return { ok : false , status : 503 , error : 'This ProdDash cannot verify a PIN (needs 1.4.0 or newer)' , code : 'no_shell_port' };
let res ;
try {
res = await fetch ( `http://127.0.0.1: ${ shellPort } /api/modules/ ${ SENDER_ID } /verify` , {
headers : { Authorization : `Bearer ${ token } ` , Accept : 'application/json' },
signal : AbortSignal . timeout ( 5000 ),
});
} catch ( err ) {
return { ok : false , status : 502 , error : `Cannot reach the Send Message module ( ${ err . cause ? . code || err . message } )` , code : 'sender_unreachable' };
}
if ( res . status === 404 ) return { ok : false , status : 503 , error : 'Install the Send Message module to send from a dashboard' , code : 'no_sender' };
let body = {};
try { body = await res . json (); } catch { /* a non-JSON refusal */ }
if ( res . status === 401 || body ? . ok === false ) return { ok : false , status : 401 , error : body ? . error || 'Locked — enter the PIN' , code : body ? . code || 'locked' };
if ( ! res . ok ) return { ok : false , status : 502 , error : `Send Message module answered HTTP ${ res . status } ` , code : 'sender_error' };
return { ok : true , expiresAt : Number ( body ? . expiresAt ) || 0 };
}
2026-09-15 12:43:08 -04:00
let current = null ; // the running instance, for routes()
2026-08-30 15:03:12 +00:00
module . exports = {
2026-10-05 13:55:55 -04:00
init ({ config , log , shell }) {
const shellPort = Number ( shell && shell . port ) || 0 ;
2026-09-15 12:43:08 -04:00
const target = resolveTarget ( config );
const apiKey = String ( config . apiKey || '' );
const channels = [];
const groups = [];
const entries = new Map (); // id → entry, in arrival order
const streams = new Set (); // browsers' SSE responses
let status = { state : 'connecting' , message : target ? 'Connecting to ProdCom…' : 'No ProdCom server configured' };
let stopped = false ;
let upstream = null ;
let generation = 0 ; // callbacks from a superseded connection are ignored
let retryTimer = null ;
let retryMs = RETRY_MIN_MS ;
const authHeaders = () => ( apiKey ? { authorization : `Bearer ${ apiKey } ` } : {});
async function getJson ( pathname ) {
const res = await fetch ( new URL ( pathname , target ), {
headers : { accept : 'application/json' , ... authHeaders () },
signal : AbortSignal . timeout ( REQUEST_TIMEOUT_MS ),
});
if ( ! res . ok ) throw new Error ( `HTTP ${ res . status } for ${ pathname } ` );
return res . json ();
}
function sortedEntries () {
return [... entries . values ()]. sort (( a , b ) => new Date ( a . date ) - new Date ( b . date ));
}
function snapshot () {
return { status , channels , groups , entries : sortedEntries () };
}
function broadcast ( event , data ) {
if ( ! streams . size ) return ;
const frame = `event: ${ event } \ndata: ${ JSON . stringify ( data ) } \n\n` ;
for ( const res of streams ) {
try {
res . write ( frame );
} catch {
streams . delete ( res );
}
}
}
function setStatus ( state , message ) {
if ( status . state === state && status . message === message ) return ;
status = { state , message };
broadcast ( 'status' , status );
}
function trim () {
while ( entries . size > ENTRIES_MAX ) entries . delete ( entries . keys (). next (). value );
}
/** A changed entry: kept, and told to every browser. */
function upsert ( raw ) {
const e = normalizeEntry ( raw );
const prev = entries . get ( e . id );
if ( prev && prev . text === e . text && prev . inProgress === e . inProgress && prev . channelId === e . channelId ) return ;
entries . set ( e . id , e );
trim ();
broadcast ( 'entry' , e );
}
function clearAll () {
entries . clear ();
broadcast ( 'clear' , {});
}
async function loadLists () {
const [ ch , gr ] = await Promise . all ([
getJson ( '/api/v1/channels' ),
getJson ( '/api/v1/groups' ). catch (() => ({ data : [] })), // groups are optional in ProdCom
]);
2026-10-05 13:55:55 -04:00
channels . splice ( 0 , channels . length , ...( ch . data || []). map (( c ) => ({
id : String ( c . id ),
name : String ( c . name || c . id ),
color : c . color || '#8b98a5' ,
// whether ProdCom lets text be typed onto this channel (an older
// ProdCom that doesn't say is taken at its word when asked)
typingEnabled : c . typingEnabled !== false ,
})));
2026-09-15 12:43:08 -04:00
groups . splice ( 0 , groups . length , ...( gr . data || []). map (( g ) => ({ id : String ( g . id ), name : String ( g . name || g . id ), channelIds : Array . isArray ( g . channelIds ) ? g . channelIds . map ( String ) : [] })));
}
async function loadHistory () {
const body = await getJson ( `/api/v1/transcript?limit= ${ HISTORY_LIMIT } ` );
for ( const e of extractEntries ( body ). map ( normalizeEntry )) entries . set ( e . id , e );
trim ();
}
function handleFrame ( block ) {
const data = [];
for ( const line of block . split ( '\n' )) {
if ( line . startsWith ( 'data:' )) data . push ( line . slice ( 5 ). replace ( /^ / , '' ));
}
if ( ! data . length ) return ;
let obj ;
try {
obj = JSON . parse ( data . join ( '\n' ));
} catch {
return ;
}
const kind = String ( obj ? . type || obj ? . event || '' ). toLowerCase ();
if ( kind . includes ( 'clear' )) return clearAll ();
extractEntries ( obj ). forEach ( upsert );
}
function scheduleRetry () {
if ( stopped ) return ;
clearTimeout ( retryTimer );
retryTimer = setTimeout ( connect , retryMs );
retryMs = Math . min ( RETRY_MAX_MS , retryMs * 2 );
}
function fail ( gen , why ) {
if ( stopped || gen !== generation ) return ;
generation += 1 ; // whatever else this connection reports is stale now
try { upstream ? . destroy (); } catch { /* already gone */ }
upstream = null ;
setStatus ( 'error' , `ProdCom unreachable ( ${ why } ) — retrying` );
log ( `ProdCom ${ target . origin } : ${ why } — retrying in ${ retryMs / 1000 } s` );
scheduleRetry ();
}
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
async function connect () {
if ( stopped || ! target ) return ;
const gen = ++ generation ;
try {
await loadLists ();
await loadHistory ();
} catch ( err ) {
if ( gen !== generation || stopped ) return ;
return fail ( gen , err . cause ? . code || err . code || err . message );
}
if ( gen !== generation || stopped ) return ;
broadcast ( 'state' , snapshot ());
const url = new URL ( '/api/v1/transcript/stream' , target );
const req = http . request ({
hostname : url . hostname ,
port : url . port || 80 ,
path : url . pathname ,
method : 'GET' ,
headers : { accept : 'text/event-stream' , ... authHeaders () },
}, ( res ) => {
if ( gen !== generation ) return void res . destroy ();
if ( res . statusCode !== 200 ) {
res . resume ();
return fail ( gen , `HTTP ${ res . statusCode } from the transcript stream` );
}
retryMs = RETRY_MIN_MS ;
setStatus ( 'ok' , 'Live' );
log ( `following ${ target . origin } — ${ channels . length } channels, ${ entries . size } entries` );
let buf = '' ;
res . setEncoding ( 'utf8' );
res . on ( 'data' , ( chunk ) => {
if ( gen !== generation ) return ;
buf += chunk ;
let i ;
while (( i = buf . indexOf ( '\n\n' )) >= 0 ) {
handleFrame ( buf . slice ( 0 , i ));
buf = buf . slice ( i + 2 );
}
if ( buf . length > 1e6 ) buf = '' ; // a frame that never ends is dropped
});
res . on ( 'end' , () => fail ( gen , 'stream ended' ));
res . on ( 'error' , ( err ) => fail ( gen , err . code || err . message ));
});
req . on ( 'error' , ( err ) => fail ( gen , err . code || err . message ));
req . end ();
upstream = req ;
}
const listsTimer = setInterval (() => {
if ( stopped || status . state !== 'ok' ) return ;
loadLists (). then (() => broadcast ( 'state' , snapshot ())). catch (() => { /* the next reconnect re-reads them */ });
}, REFRESH_LISTS_MS );
listsTimer . unref ? .();
const heartbeat = setInterval (() => {
for ( const res of streams ) {
try {
res . write ( ': ping\n\n' );
} catch {
streams . delete ( res );
}
}
}, HEARTBEAT_MS );
heartbeat . unref ? .();
if ( target ) {
log ( `one connection to ProdCom at ${ target . origin } , shared by every dashboard` );
connect ();
} else {
log ( 'no ProdCom server configured — set the endpoint in /admin' );
}
2026-10-05 13:55:55 -04:00
/** A channel by id, or by name (case-insensitive) — ProdCom accepts both, the tiles use ids. */
function channelByRef ( ref ) {
const s = String ( ref ?? '' ). trim ();
if ( ! s ) return null ;
return channels . find (( c ) => c . id === s ) || channels . find (( c ) => c . name . toLowerCase () === s . toLowerCase ()) || null ;
}
/**
* What the Send Message module may send as: the channels that accept
* typed text. A ProdCom channel is an identity, not a recipient — text
* typed onto it goes out *as* that channel — so the destinations are
* declared as such, and a channel with typing off is left out.
*/
function messagingDescriptor () {
const h = current ? current . health () : { status : 'connecting' , message : 'Restarting…' };
return {
label : 'ProdCom' ,
identity : '' ,
destinationRole : 'identity' ,
status : { state : h . status , message : h . message },
destinations : channels . filter (( c ) => c . typingEnabled ). map (( c ) => ({ id : c . id , label : c . name , color : c . color || '' })),
emptyMessage : channels . length
? 'No ProdCom channel accepts typed text — turn on typing for one in ProdCom'
: 'No ProdCom channels yet' ,
maxChars : MAX_TEXT_CHARS ,
};
}
/** Typed text onto a channel: the one ProdCom connection does the talking. */
async function sendText ( ch , text ) {
if ( ! target ) return { status : 502 , error : 'No ProdCom server configured — set the endpoint in Admin → ProdCom' , code : 'no_target' };
let res ;
try {
res = await fetch ( new URL ( `/api/v1/channels/ ${ encodeURIComponent ( ch . id ) } /send` , target ), {
method : 'POST' ,
headers : { 'content-type' : 'application/json; charset=utf-8' , accept : 'application/json' , ... authHeaders () },
body : JSON . stringify ({ text }),
signal : AbortSignal . timeout ( REQUEST_TIMEOUT_MS ),
});
} catch ( err ) {
return { status : 502 , error : `ProdCom unreachable ( ${ err . cause ? . code || err . code || err . message } ) — not sent` , code : 'network' };
}
if ( res . ok ) {
res . body ? . cancel ? .(). catch ? .(() => {});
return { status : 200 };
}
let detail = '' ;
let code = '' ;
try {
const body = await res . json ();
detail = String ( body ? . error ? . message || '' );
code = String ( body ? . error ? . code || '' );
} catch { /* no JSON */ }
const tail = detail ? ` — ${ detail } ` : '' ;
if ( res . status === 401 ) return { status : 502 , error : `ProdCom rejected the API key ( ${ code || 'UNAUTHORIZED' } ) — check Admin → ProdCom` , code : 'unauthorized' };
if ( res . status === 404 ) return { status : 404 , error : `ProdCom has no channel " ${ ch . name } " any more ( ${ code || 'NOT_FOUND' } ) — pick another in Send as` , code : 'not_found' };
if ( res . status === 429 ) return { status : 429 , error : `ProdCom is rate-limiting — try again in a moment ${ tail } ` , code : 'ratelimited' };
if ( res . status === 400 || res . status === 422 ) return { status : 400 , error : `ProdCom refused the message ( ${ code || res . status } ) ${ tail } ` , code : 'refused' };
return { status : 502 , error : `ProdCom answered HTTP ${ res . status }${ tail } ` , code : code || `http_ ${ res . status } ` };
}
2026-09-15 12:43:08 -04:00
current = {
snapshot ,
streams ,
2026-10-05 13:55:55 -04:00
shellPort ,
channelByRef ,
messagingDescriptor ,
sendText ,
log ,
2026-09-15 12:43:08 -04:00
health () {
if ( status . state === 'ok' ) return { status : 'ok' , message : `Live — ${ channels . length } channel ${ channels . length === 1 ? '' : 's' } , ${ entries . size } entries` };
2026-09-16 10:37:56 -04:00
// Nothing to connect to is a setting that is missing, not a link that
// is coming up — it reads red, like the other modules say it.
if ( ! target ) return { status : 'error' , message : status . message };
2026-09-15 12:43:08 -04:00
return { status : status . state === 'error' ? 'error' : 'connecting' , message : status . message };
},
};
2026-08-30 15:03:12 +00:00
return {
2026-09-15 12:43:08 -04:00
stop () {
stopped = true ;
generation += 1 ;
clearTimeout ( retryTimer );
clearInterval ( listsTimer );
clearInterval ( heartbeat );
try { upstream ? . destroy (); } catch { /* already gone */ }
for ( const res of streams ) {
try { res . end (); } catch { /* gone */ }
}
streams . clear ();
if ( current ? . streams === streams ) current = null ;
},
health : () => ( current ? current . health () : { status : 'connecting' , message : 'Restarting…' }),
2026-08-30 15:03:12 +00:00
};
},
2026-09-15 12:43:08 -04:00
routes () {
2026-10-05 13:55:55 -04:00
const unmounted = ( res ) => sendJson ( res , 503 , { error : 'ProdCom module is restarting — try again' });
2026-08-30 15:03:12 +00:00
return {
2026-09-15 12:43:08 -04:00
'GET /state' : ( req , res ) => {
2026-10-05 13:55:55 -04:00
if ( ! current ) return unmounted ( res );
2026-09-15 12:43:08 -04:00
sendJson ( res , 200 , current . snapshot ());
},
2026-10-05 13:55:55 -04:00
// The Send Message module reads this over loopback: where it may send.
'GET /messaging' : ( req , res ) => {
if ( ! current ) return unmounted ( res );
sendJson ( res , 200 , current . messagingDescriptor ());
},
// …and hands a message here, with the browser's unlock token. The token
// is checked with the Send Message module first: no PIN entered there,
// nothing goes out on a channel.
'POST /messaging/send' : guard ( async ( req , res ) => {
if ( ! current ) return unmounted ( res );
const v = await verifyUnlock ( req , current . shellPort );
if ( ! v . ok ) return sendJson ( res , v . status , { error : v . error , code : v . code });
let body ;
try {
body = await readJson ( req );
} catch ( err ) {
return sendJson ( res , 400 , { error : err . message });
}
const text = String ( body . text ?? '' ). replace ( /\r\n/g , '\n' ). trim ();
if ( ! text ) return sendJson ( res , 400 , { error : 'Nothing to send' });
if ( text . length > MAX_TEXT_CHARS ) return sendJson ( res , 400 , { error : `Too long — ${ MAX_TEXT_CHARS } characters at most` });
const ch = current . channelByRef ( body . destination );
if ( ! ch ) {
const s = String ( body . destination ?? '' ). trim ();
return sendJson ( res , 400 , { error : s ? `No ProdCom channel " ${ s } " — pick another in Send as` : 'Which ProdCom channel?' , code : 'bad_destination' });
}
if ( ! ch . typingEnabled ) {
return sendJson ( res , 400 , { error : ` ${ ch . name } doesn't accept typed text — turn on typing for it in ProdCom` , code : 'typing_off' });
}
const out = await current . sendText ( ch , text );
if ( out . status !== 200 ) {
current . log ( `send to ${ ch . name } failed: ${ out . error } ` );
return sendJson ( res , out . status , { error : out . error , code : out . code });
}
const preview = text . replace ( /\s+/g , ' ' ). slice ( 0 , 60 );
current . log ( `sent to ${ ch . name } : " ${ preview }${ text . length > 60 ? '…' : '' } "` );
sendJson ( res , 200 , { ok : true , label : ch . name , expiresAt : v . expiresAt });
}),
2026-09-15 12:43:08 -04:00
'GET /stream' : ( req , res ) => {
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 ) {
res . write ( `event: state\ndata: ${ JSON . stringify ({ status : { state : 'connecting' , message : 'ProdCom module is restarting…' } , channels: [], groups: [], entries: [] })}\n\n` );
return void res . end ();
}
res . write ( `event: state\ndata: ${ JSON . stringify ( current . snapshot ()) } \n\n` );
try { req . socket . setKeepAlive ( true , 15000 ); } catch { /* gone */ }
current . streams . add ( res );
req . on ( 'close' , () => current && current . streams . delete ( res ));
},
2026-08-30 15:03:12 +00:00
};
},
};