// Player runtime state, websocket connections, and request-auth enforcement. const crypto = require('crypto'); const { WebSocketServer, WebSocket } = require('ws'); const { findAvailableClientName, isClientNameAvailable } = require('#src/data/client-name-check'); const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth'); const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth'; function normalizePlayerPublicBaseUrl(pageUrl) { const value = String(pageUrl || '').trim(); if (!value) { return null; } try { return new URL(value).origin.replace(/\/$/, ''); } catch (_error) { return null; } } function createPlayerRuntime(options) { const pool = options && options.pool ? options.pool : null; const notifySnapshot = typeof options.notifySnapshot === 'function' ? options.notifySnapshot : null; const persistClientName = typeof options.persistClientName === 'function' ? options.persistClientName : null; const touchClientLastSeen = typeof options.touchClientLastSeen === 'function' ? options.touchClientLastSeen : null; const normalizeDeviceId = typeof options.normalizeDeviceId === 'function' ? options.normalizeDeviceId : function (value) { return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); }; const connectionsBySlug = new Map(); const dashboardListenersBySlug = new Map(); const announcementListenersBySlug = new Map(); const pendingCommandAcks = new Map(); const wss = new WebSocketServer({ noServer: true }); const staleConnectionMs = Number(options && options.staleConnectionMs) > 0 ? Number(options.staleConnectionMs) : 3 * 60 * 1000; function hasActiveClientId(clientId, currentConnection) { const normalizedClientId = String(clientId || '').trim(); if (!normalizedClientId) { return false; } for (const connections of connectionsBySlug.values()) { for (const connection of connections.values()) { if (connection !== currentConnection && String(connection.clientId || '').trim() === normalizedClientId) { return true; } } } return false; } function normalizeClientIp(value) { const ip = String(value || '').trim(); if (!ip) { return null; } if (ip.toLowerCase().startsWith('::ffff:')) { return ip.slice(7).trim() || null; } return ip; } function firstHeaderValue(value) { return String(value || '').trim().split(',')[0].trim(); } function isPrivateOrReservedIp(ip) { const normalized = normalizeClientIp(ip); if (!normalized) { return true; } const lower = normalized.toLowerCase(); if (lower === 'localhost' || lower === '::1') { return true; } if (/^10\./.test(lower) || /^192\.168\./.test(lower) || /^127\./.test(lower) || /^169\.254\./.test(lower)) { return true; } if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(lower)) { return true; } if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80:') || lower.startsWith('::ffff:127.')) { return true; } return false; } function pickForwardedIp(candidates) { const normalizedCandidates = Array.isArray(candidates) ? candidates.map(function (candidate) { return normalizeClientIp(candidate); }).filter(Boolean) : []; const publicCandidate = normalizedCandidates.find(function (candidate) { return !isPrivateOrReservedIp(candidate); }); return publicCandidate || normalizedCandidates[0] || null; } function resolveRequestIp(request) { const forwardedFor = String(request && request.headers && request.headers['x-forwarded-for'] || '').split(','); const forwardedForIp = pickForwardedIp(forwardedFor); if (forwardedForIp) { return forwardedForIp; } const realIp = pickForwardedIp([firstHeaderValue(request && request.headers && request.headers['x-real-ip'])]); if (realIp) { return realIp; } const forwarded = firstHeaderValue(request && request.headers && request.headers.forwarded); if (forwarded) { const forwardedMatches = Array.from(forwarded.matchAll(/(?:^|,\s*|;\s*)for=(?:"?\[?)([^"\];,\s]+)/gi)).map(function (match) { return match[1]; }); const forwardedIp = pickForwardedIp(forwardedMatches); if (forwardedIp) { return forwardedIp; } } return normalizeClientIp(request && request.socket && request.socket.remoteAddress); } function parseCookies(cookieHeader) { return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) { if (!pair) { return cookies; } const separatorIndex = pair.indexOf('='); if (separatorIndex === -1) { return cookies; } const name = decodeURIComponent(pair.slice(0, separatorIndex).trim()); const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim()); if (name) { cookies[name] = value; } return cookies; }, {}); } function readPageAuthToken(request) { const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim(); if (queryToken) { return queryToken; } const cookies = parseCookies(request.headers && request.headers.cookie || ''); return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim(); } function getConnectionBucket(slug) { const key = String(slug || '').trim(); if (!key) { return null; } if (!connectionsBySlug.has(key)) { connectionsBySlug.set(key, new Map()); } return connectionsBySlug.get(key); } function removeConnection(slug, connectionId) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket) { return; } bucket.delete(connectionId); if (!bucket.size) { connectionsBySlug.delete(String(slug || '').trim()); } } function removeStaleConnections() { const cutoff = Date.now() - staleConnectionMs; for (const [slug, bucket] of connectionsBySlug.entries()) { for (const [connectionId, connection] of bucket.entries()) { if (connection.lastSeenAt && connection.lastSeenAt.getTime() > cutoff) { continue; } removeConnection(slug, connectionId); try { connection.socket.close(1000, 'Connection heartbeat expired.'); } catch (_error) { } broadcastConnectionSnapshot(slug); } } } const staleConnectionSweep = setInterval(removeStaleConnections, Math.min(staleConnectionMs, 60 * 1000)); if (typeof staleConnectionSweep.unref === 'function') { staleConnectionSweep.unref(); } function checkWebsocketHealth() { for (const [slug, bucket] of connectionsBySlug.entries()) { for (const [connectionId, connection] of bucket.entries()) { if (!connection.isAlive) { removeConnection(slug, connectionId); try { connection.socket.terminate(); } catch (_error) { } broadcastConnectionSnapshot(slug); continue; } connection.isAlive = false; try { connection.socket.ping(); } catch (_error) { removeConnection(slug, connectionId); broadcastConnectionSnapshot(slug); } } } } const websocketHealthCheck = setInterval(checkWebsocketHealth, 30 * 1000); if (typeof websocketHealthCheck.unref === 'function') { websocketHealthCheck.unref(); } function getDashboardListenerBucket(slug) { const key = String(slug || '').trim(); if (!key) { return null; } if (!dashboardListenersBySlug.has(key)) { dashboardListenersBySlug.set(key, new Set()); } return dashboardListenersBySlug.get(key); } function removeDashboardListener(slug, socket) { const key = String(slug || '').trim(); const bucket = dashboardListenersBySlug.get(key); if (!bucket) { return; } bucket.delete(socket); if (!bucket.size) { dashboardListenersBySlug.delete(key); } } function getAnnouncementListenerBucket(slug) { const key = String(slug || '').trim(); if (!key) { return null; } if (!announcementListenersBySlug.has(key)) { announcementListenersBySlug.set(key, new Set()); } return announcementListenersBySlug.get(key); } function removeAnnouncementListener(slug, socket) { const key = String(slug || '').trim(); const bucket = announcementListenersBySlug.get(key); if (!bucket) { return; } bucket.delete(socket); if (!bucket.size) { announcementListenersBySlug.delete(key); } } function buildClientLabel(connection) { const clientName = String(connection.clientName || '').trim(); const clientId = String(connection.clientId || '').trim(); const userAgent = String(connection.userAgent || '').trim(); const viewport = connection.viewport && typeof connection.viewport === 'object' ? connection.viewport : null; const labelParts = []; if (userAgent) { labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent); } if (clientName) { labelParts.push(clientName); } else if (clientId) { labelParts.push(`id ${clientId.slice(-6)}`); } if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) { labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`); } if (!labelParts.length) { return connection.remoteAddress || 'connected client'; } return labelParts.join(' • '); } function snapshotConnections(slug) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket) { return []; } return Array.from(bucket.values()).map(function (connection) { return { id: connection.id, clientId: connection.clientId || null, clientName: connection.clientName || null, deviceId: connection.deviceId || null, label: connection.label, userAgent: connection.userAgent || null, viewport: connection.viewport || null, page: connection.page || null, playerPublicBaseUrl: connection.playerPublicBaseUrl || null, currentSlide: connection.currentSlide || null, paused: Boolean(connection.paused), blackout: Boolean(connection.blackout), currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null, currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null, connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null, lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null }; }); } function snapshotAllConnections() { const allConnections = []; for (const bucket of connectionsBySlug.values()) { if (!bucket || typeof bucket.values !== 'function') { continue; } for (const connection of bucket.values()) { allConnections.push({ clientId: connection.clientId || null, clientName: connection.clientName || null, deviceId: connection.deviceId || null }); } } return allConnections; } function snapshotSlugs() { return Array.from(connectionsBySlug.keys()); } async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) { return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections()); } function broadcastConnectionSnapshot(slug) { const key = String(slug || '').trim(); const bucket = dashboardListenersBySlug.get(key); const connections = snapshotConnections(slug); if (notifySnapshot) { try { notifySnapshot({ slug: key, connections: connections }); } catch (_error) { } } if (!bucket || !bucket.size) { return; } const payload = JSON.stringify({ type: 'snapshot', slug: key, connections: connections, sentAt: new Date().toISOString() }); bucket.forEach(function (socket) { if (socket.readyState === WebSocket.OPEN) { socket.send(payload); } }); } function broadcastAnnouncementRefresh(slug) { const key = String(slug || '').trim(); const bucket = announcementListenersBySlug.get(key); if (!bucket || !bucket.size) { return 0; } const payload = JSON.stringify({ type: 'announcement-refresh', slug: key, sentAt: new Date().toISOString() }); let sent = 0; bucket.forEach(function (socket) { if (socket.readyState === WebSocket.OPEN) { socket.send(payload); sent += 1; } }); return sent; } async function sendCommandToConnection(slug, connectionId, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { return 0; } const normalizedConnectionId = String(connectionId || '').trim(); const target = bucket.get(normalizedConnectionId) || Array.from(bucket.values()).find(function (connection) { return String(connection && connection.clientId || '').trim() === normalizedConnectionId; }); if (!target || target.socket.readyState !== WebSocket.OPEN) { return 0; } const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) : { command: commandOrPayload }; payload.type = 'command'; payload.targetConnectionId = target.id; payload.sentAt = new Date().toISOString(); const requestId = String(payload.requestId || '').trim(); if (!requestId) { target.socket.send(JSON.stringify(payload)); return 1; } const acknowledgement = new Promise(function (resolve) { const timeout = setTimeout(function () { pendingCommandAcks.delete(requestId); resolve(0); }, 5000); pendingCommandAcks.set(requestId, { connection: target, resolve: function (acknowledged) { clearTimeout(timeout); pendingCommandAcks.delete(requestId); resolve(acknowledged ? 1 : 0); } }); }); target.socket.send(JSON.stringify(payload)); return await acknowledgement; } async function broadcastCommand(slug, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { return 0; } let sent = 0; bucket.forEach(function (connection) { if (connection.socket.readyState !== WebSocket.OPEN) { return; } const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null ? Object.assign({}, commandOrPayload) : { command: commandOrPayload }; payload.type = 'command'; payload.sentAt = new Date().toISOString(); connection.socket.send(JSON.stringify(payload)); sent += 1; }); return sent; } function handleUpgrade(request, socket, head) { let pathname = ''; try { pathname = new URL(request.url, 'http://localhost').pathname; } catch (_error) { socket.destroy(); return; } const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/); const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/); const announcementMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/announcements$/); if (!dashboardMatch && !playerMatch && !announcementMatch) { socket.destroy(); return; } if (dashboardMatch) { if (!verifyRequestAuth(request)) { socket.destroy(); return; } } if (playerMatch) { const authToken = readPageAuthToken(request); const payload = verifyPageAuthToken(authToken); if (!payload || String(payload.scope || '').trim() !== 'player') { socket.destroy(); return; } } if (announcementMatch) { const authToken = readPageAuthToken(request); const payload = verifyPageAuthToken(authToken); if (!payload || String(payload.scope || '').trim() !== 'player') { socket.destroy(); return; } } const slug = decodeURIComponent((dashboardMatch || playerMatch || announcementMatch)[1]); wss.handleUpgrade(request, socket, head, function (ws) { wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : announcementMatch ? 'announcements' : 'player'); }); } wss.on('connection', function (socket, request, slug, role) { if (role === 'dashboard') { const listenerBucket = getDashboardListenerBucket(slug); if (!listenerBucket) { socket.close(); return; } listenerBucket.add(socket); socket.send(JSON.stringify({ type: 'snapshot', slug: String(slug || '').trim(), connections: snapshotConnections(slug), sentAt: new Date().toISOString() })); socket.on('close', function () { removeDashboardListener(slug, socket); }); socket.on('error', function () { removeDashboardListener(slug, socket); }); return; } if (role === 'announcements') { const listenerBucket = getAnnouncementListenerBucket(slug); if (!listenerBucket) { socket.close(); return; } listenerBucket.add(socket); socket.send(JSON.stringify({ type: 'announcement-ready', slug: String(slug || '').trim(), sentAt: new Date().toISOString() })); socket.on('close', function () { removeAnnouncementListener(slug, socket); }); socket.on('error', function () { removeAnnouncementListener(slug, socket); }); return; } const connectionId = crypto.randomUUID(); const connection = { id: connectionId, slug: slug, socket: socket, clientId: null, clientName: null, deviceId: null, userAgent: null, viewport: null, page: null, paused: false, blackout: false, playerPublicBaseUrl: null, label: 'connected client', connectedAt: new Date(), lastSeenAt: new Date() }; connection.isAlive = true; const bucket = getConnectionBucket(slug); if (!bucket) { socket.close(); return; } bucket.set(connectionId, connection); socket.on('pong', function () { connection.isAlive = true; }); socket.on('message', async function (rawMessage) { connection.lastSeenAt = new Date(); let payload = null; try { payload = JSON.parse(String(rawMessage || '')); } catch (_error) { return; } if (!payload || payload.type !== 'state') { if (payload && payload.type === 'command-ack') { const requestId = String(payload.requestId || '').trim(); const pendingAck = pendingCommandAcks.get(requestId); if (pendingAck && pendingAck.connection === connection) { pendingAck.resolve(payload.ok !== false); } } return; } const nextClientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId; if (nextClientId && hasActiveClientId(nextClientId, connection)) { socket.send(JSON.stringify({ type: 'client-id-conflict' })); socket.close(4009, 'Client ID is already in use.'); return; } connection.clientId = nextClientId; connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName; connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId; if (!connection.clientName && connection.clientId) { connection.clientName = connection.clientId; } if (connection.clientName) { const connectionIdentity = connection.deviceId || connection.clientId; const selectedClientName = await findAvailableClientName(pool, connection.clientName, connectionIdentity, snapshotAllConnections()); if (selectedClientName && selectedClientName !== connection.clientName) { connection.clientName = selectedClientName; if (persistClientName) { await persistClientName(connection.deviceId, selectedClientName); } if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'client-name-updated', clientName: selectedClientName })); } } } if (touchClientLastSeen) { try { await touchClientLastSeen(connection.clientId); } catch (_error) { // A heartbeat failure must not interrupt playback or websocket state. } } connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent; connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport; connection.page = payload.page ? String(payload.page).trim() : connection.page; const nextPlayerPublicBaseUrl = normalizePlayerPublicBaseUrl(payload.page); if (nextPlayerPublicBaseUrl && nextPlayerPublicBaseUrl !== connection.playerPublicBaseUrl) { connection.playerPublicBaseUrl = nextPlayerPublicBaseUrl; } connection.paused = Boolean(payload.paused); connection.blackout = Boolean(payload.blackout); connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? { id: payload.currentSlide.id || null, title: payload.currentSlide.title || '', kind: payload.currentSlide.kind || '', playlistSignature: payload.currentSlide.playlistSignature || '' } : connection.currentSlide; connection.label = buildClientLabel(connection); connection.lastSeenAt = new Date(); broadcastConnectionSnapshot(slug); }); socket.on('close', function (code, reason) { pendingCommandAcks.forEach(function (pendingAck, requestId) { if (pendingAck.connection === connection) { pendingAck.resolve(false); pendingCommandAcks.delete(requestId); } }); removeConnection(slug, connectionId); broadcastConnectionSnapshot(slug); }); socket.on('error', function () { pendingCommandAcks.forEach(function (pendingAck, requestId) { if (pendingAck.connection === connection) { pendingAck.resolve(false); pendingCommandAcks.delete(requestId); } }); removeConnection(slug, connectionId); broadcastConnectionSnapshot(slug); }); }); function installWebsocket(server) { server.on('upgrade', handleUpgrade); } return { installWebsocket: installWebsocket, broadcastAnnouncementRefresh: broadcastAnnouncementRefresh, snapshotConnections: snapshotConnections, snapshotAllConnections: snapshotAllConnections, snapshotSlugs: snapshotSlugs, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, sendCommandToConnection: sendCommandToConnection, broadcastCommand: broadcastCommand }; } module.exports = { createPlayerRuntime: createPlayerRuntime };