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
+208
View File
@@ -0,0 +1,208 @@
const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./dashboard-state');
const { createUploadSyncService } = require('./upload-sync');
function createWebBootstrap(options) {
const pool = options && options.pool;
const common = options && options.common;
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
const uploadDir = String(options && options.uploadDir || '').trim();
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000);
const formatDashboardDate = options && options.formatDashboardDate;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
if (!pool || !common || !uploadDir || typeof formatDashboardDate !== 'function' || typeof notifyPlayerScreens !== 'function') {
throw new Error('createWebBootstrap requires the web bootstrap dependencies.');
}
const dashboardWs = new WebSocketServer({ noServer: true });
const dashboardClients = new Set();
const playerSnapshotCache = new Map();
const playerSnapshotSockets = new Map();
let dashboardRefreshInFlight = null;
let broadcastDashboardState = null;
function getPlayerSnapshotSocketUrl(slug) {
const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws'));
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
url.search = '';
return url.toString();
}
function storePlayerSnapshot(slug, connections) {
const normalizedSlug = String(slug || '').trim();
const normalizedConnections = Array.isArray(connections) ? connections : [];
playerSnapshotCache.set(normalizedSlug, {
slug: normalizedSlug,
count: normalizedConnections.length,
connections: normalizedConnections
});
}
function clearPlayerSnapshotSocket(slug) {
const key = String(slug || '').trim();
playerSnapshotSockets.delete(key);
}
function ensurePlayerSnapshotSubscription(slug) {
const key = String(slug || '').trim();
if (!key || playerSnapshotSockets.has(key)) {
return;
}
const socket = new WebSocket(getPlayerSnapshotSocketUrl(key));
playerSnapshotSockets.set(key, socket);
socket.onmessage = function (event) {
try {
const payload = JSON.parse(String(event.data || '{}'));
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
return;
}
storePlayerSnapshot(key, payload.connections || []);
if (broadcastDashboardState) {
broadcastDashboardState().catch(function (error) {
console.error(error);
});
}
} catch (_error) {
// Ignore malformed player snapshot payloads.
}
};
socket.onclose = function () {
clearPlayerSnapshotSocket(key);
setTimeout(function () {
ensurePlayerSnapshotSubscription(key);
}, 2000);
};
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
}
const dashboardStateService = createDashboardStateService({
pool: pool,
common: common,
playerSnapshotCache: playerSnapshotCache,
playerSnapshotSockets: playerSnapshotSockets,
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
playerPublicBaseUrl: playerPublicBaseUrl,
formatDashboardDate: formatDashboardDate
});
const buildDashboardState = dashboardStateService.buildDashboardState;
const uploadSyncService = createUploadSyncService({
common: common,
playerInternalBaseUrl: playerInternalBaseUrl,
playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens
});
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
async function sendDashboardStateToSocket(socket) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const state = await buildDashboardState();
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
}
broadcastDashboardState = async function () {
if (dashboardRefreshInFlight) {
return dashboardRefreshInFlight;
}
dashboardRefreshInFlight = (async function () {
const state = await buildDashboardState();
const payload = JSON.stringify({ type: 'dashboard-state', state: state });
for (const socket of dashboardClients) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
}
return state;
})().finally(function () {
dashboardRefreshInFlight = null;
});
return dashboardRefreshInFlight;
};
function installDashboardWebsocket(server, loadCurrentUser) {
server.on('upgrade', async function (request, socket, head) {
let pathname = '';
try {
pathname = new URL(request.url, 'http://localhost').pathname;
} catch (_error) {
socket.destroy();
return;
}
if (pathname !== '/ws/admin/dashboard') {
socket.destroy();
return;
}
try {
const currentUser = await loadCurrentUser(pool, request);
if (!currentUser) {
socket.destroy();
return;
}
} catch (_error) {
socket.destroy();
return;
}
dashboardWs.handleUpgrade(request, socket, head, function (ws) {
dashboardWs.emit('connection', ws, request);
});
});
dashboardWs.on('connection', function (socket) {
dashboardClients.add(socket);
sendDashboardStateToSocket(socket);
socket.on('close', function () {
dashboardClients.delete(socket);
});
socket.on('error', function () {
dashboardClients.delete(socket);
});
});
setInterval(function () {
broadcastDashboardState().catch(function (error) {
console.error(error);
});
}, dashboardRefreshIntervalMs);
}
return {
upload: upload,
buildDashboardState: buildDashboardState,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
broadcastDashboardState: broadcastDashboardState,
installDashboardWebsocket: installDashboardWebsocket
};
}
module.exports = { createWebBootstrap };