const crypto = require('crypto'); const { WebSocketServer, WebSocket } = require('ws'); const { isClientNameAvailable } = require('../data/client-name-check'); const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth'); function createPlayerRuntime(options) { const pool = options && options.pool ? options.pool : 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 wss = new WebSocketServer({ noServer: true }); 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 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 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 buildClientLabel(connection) { const clientName = String(connection.clientName || '').trim(); const clientId = String(connection.clientId || '').trim(); const userAgent = String(connection.userAgent || '').trim(); const clientIp = String(connection.clientIp || '').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 (clientIp) { labelParts.push(clientIp); } 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, 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, clientIp: connection.clientIp || null, remoteAddress: connection.remoteAddress || 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; } 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); if (!bucket || !bucket.size) { return; } const payload = JSON.stringify({ type: 'snapshot', slug: key, connections: snapshotConnections(slug), sentAt: new Date().toISOString() }); bucket.forEach(function (socket) { if (socket.readyState === WebSocket.OPEN) { socket.send(payload); } }); } async function sendCommandToConnection(slug, connectionId, commandOrPayload) { const bucket = connectionsBySlug.get(String(slug || '').trim()); if (!bucket || !bucket.size) { return 0; } const target = bucket.get(String(connectionId || '').trim()); 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(); target.socket.send(JSON.stringify(payload)); return 1; } 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\/([^/]+)$/); if (!dashboardMatch && !playerMatch) { socket.destroy(); return; } if (dashboardMatch) { if (!verifyRequestAuth(request)) { socket.destroy(); return; } } if (playerMatch) { const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim(); const payload = verifyPageAuthToken(authToken); if (!payload || String(payload.scope || '').trim() !== 'player') { socket.destroy(); return; } } const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]); wss.handleUpgrade(request, socket, head, function (ws) { wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : '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; } const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null; const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]); const normalizedRemoteAddress = normalizeClientIp(remoteAddress); 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, clientIp: forwardedFor || normalizedRemoteAddress, remoteAddress: normalizedRemoteAddress, label: forwardedFor || normalizedRemoteAddress || 'connected client', connectedAt: new Date(), lastSeenAt: new Date() }; const bucket = getConnectionBucket(slug); if (!bucket) { socket.close(); return; } bucket.set(connectionId, connection); socket.on('message', function (rawMessage) { connection.lastSeenAt = new Date(); let payload = null; try { payload = JSON.parse(String(rawMessage || '')); } catch (_error) { return; } if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) { return; } connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId; 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; } 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; connection.paused = Boolean(payload.paused); connection.blackout = Boolean(payload.blackout); connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp; 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 () { removeConnection(slug, connectionId); broadcastConnectionSnapshot(slug); }); socket.on('error', function () { removeConnection(slug, connectionId); broadcastConnectionSnapshot(slug); }); }); function installWebsocket(server) { server.on('upgrade', handleUpgrade); } return { installWebsocket: installWebsocket, snapshotConnections: snapshotConnections, snapshotAllConnections: snapshotAllConnections, isClientNameAvailableOnScreen: isClientNameAvailableOnScreen, sendCommandToConnection: sendCommandToConnection, broadcastCommand: broadcastCommand }; } module.exports = { createPlayerRuntime: createPlayerRuntime };