This PR breaks the large web and player bootstrap files into smaller modules with clearer ownership.
Web changes: Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of web.js. Kept web.js focused on wiring and server startup. Fixed screen playlist reassignment so changing a screen’s playlist now triggers a refresh. Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide. Player changes: Split websocket/runtime handling into runtime.js. Split playlist assembly and revision hashing into playlist.js. Split onboarding and player HTTP routes into dedicated modules. Split render utilities and template loading into render-helpers.js. Kept player.js mostly as startup/orchestration. Validation: Rebuilt both services with Docker Compose. Smoke-checked web and player routes after the refactor. Verified get_errors was clean on the touched modules.
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
||||
const normalizedName = String(clientName || '').trim();
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
||||
const lowerName = normalizedName.toLowerCase();
|
||||
const liveDeviceIds = new Set();
|
||||
const liveClientIds = new Set();
|
||||
|
||||
for (const bucket of connectionsBySlug.values()) {
|
||||
if (!bucket || typeof bucket.values !== 'function') {
|
||||
continue;
|
||||
}
|
||||
for (const connection of bucket.values()) {
|
||||
const existingDeviceId = normalizeDeviceId(connection && connection.deviceId ? connection.deviceId : '');
|
||||
const existingClientId = normalizeDeviceId(connection && connection.clientId ? connection.clientId : '');
|
||||
if (existingDeviceId) {
|
||||
liveDeviceIds.add(existingDeviceId);
|
||||
}
|
||||
if (existingClientId) {
|
||||
liveClientIds.add(existingClientId);
|
||||
}
|
||||
const existingName = String(connection && connection.clientName ? connection.clientName : '').trim();
|
||||
if (!existingName) {
|
||||
continue;
|
||||
}
|
||||
if (existingName.toLowerCase() !== lowerName) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const activePool = poolArg || pool;
|
||||
if (!activePool || (!liveDeviceIds.size && !liveClientIds.size)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const [deviceRows] = await activePool.query(
|
||||
`SELECT device_id
|
||||
FROM player_onboarding_devices
|
||||
WHERE client_name IS NOT NULL
|
||||
AND TRIM(client_name) <> ''
|
||||
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))`,
|
||||
[normalizedName]
|
||||
);
|
||||
|
||||
for (let i = 0; i < deviceRows.length; i += 1) {
|
||||
const deviceId = normalizeDeviceId(deviceRows[i] && deviceRows[i].device_id ? deviceRows[i].device_id : '');
|
||||
if (!deviceId) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedDeviceId && deviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
if (liveDeviceIds.has(deviceId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||
sendCommandToConnection: sendCommandToConnection,
|
||||
broadcastCommand: broadcastCommand
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPlayerRuntime: createPlayerRuntime
|
||||
};
|
||||
Reference in New Issue
Block a user