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:
2026-07-20 23:58:27 +01:00
parent 480ccdbe9c
commit 2ea8d389fa
321 changed files with 12687 additions and 7080 deletions
+416
View File
@@ -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
};