270 lines
9.3 KiB
JavaScript
270 lines
9.3 KiB
JavaScript
// Web-to-player websocket bridge and dashboard state synchronizer.
|
|
|
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
const { createDashboardStateService } = require('./lib/dashboard-state');
|
|
const { createUploadSyncService } = require('./lib/media');
|
|
const { createRequestAuthHeaders } = require('#src/request-auth');
|
|
|
|
function createWebBootstrap(options) {
|
|
const pool = options && options.pool;
|
|
const common = options && options.common;
|
|
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
|
const configuredThinClientBaseUrl = String(options && options.thinClientBaseUrl || process.env.THIN_CLIENT_BASE_URL || '').trim().replace(/\/$/, '');
|
|
const uploadDir = String(options && options.uploadDir || '').trim();
|
|
const dashboardRefreshIntervalMs = 5000;
|
|
const formatDashboardDate = options && options.formatDashboardDate;
|
|
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
|
|
|
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 = options && options.playerSnapshotCache ? options.playerSnapshotCache : new Map();
|
|
const playerSnapshotSockets = options && options.playerSnapshotSockets ? options.playerSnapshotSockets : new Map();
|
|
let dashboardRefreshInFlight = null;
|
|
let broadcastDashboardState = null;
|
|
function getPlayerSnapshotSocketUrl(slug) {
|
|
const resolvedPlayerInternalBaseUrl = configuredThinClientBaseUrl || configuredPlayerInternalBaseUrl;
|
|
if (!resolvedPlayerInternalBaseUrl) {
|
|
throw new Error('Unable to resolve the player internal base URL.');
|
|
}
|
|
|
|
const url = new URL(resolvedPlayerInternalBaseUrl.replace(/^http/, 'ws'));
|
|
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
|
|
url.search = '';
|
|
return url.toString();
|
|
}
|
|
|
|
function clearPlayerSnapshotSocket(slug) {
|
|
const key = String(slug || '').trim();
|
|
playerSnapshotSockets.delete(key);
|
|
}
|
|
|
|
function ensurePlayerSnapshotSubscription(slug) {
|
|
if (options && typeof options.ensurePlayerSnapshotSubscription === 'function' && options.ensurePlayerSnapshotSubscription !== ensurePlayerSnapshotSubscription) {
|
|
return options.ensurePlayerSnapshotSubscription(slug);
|
|
}
|
|
|
|
const key = String(slug || '').trim();
|
|
if (!key || playerSnapshotSockets.has(key)) {
|
|
return;
|
|
}
|
|
|
|
playerSnapshotSockets.set(key, null);
|
|
const authHeaders = createRequestAuthHeaders({
|
|
method: 'GET',
|
|
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
|
});
|
|
|
|
Promise.resolve(getPlayerSnapshotSocketUrl(key)).then(function (socketUrl) {
|
|
const socket = new WebSocket(socketUrl, {
|
|
headers: authHeaders
|
|
});
|
|
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;
|
|
}
|
|
playerSnapshotCache.set(key, {
|
|
slug: key,
|
|
count: Array.isArray(payload.connections) ? payload.connections.length : 0,
|
|
connections: Array.isArray(payload.connections) ? 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
|
|
}
|
|
};
|
|
}).catch(function (error) {
|
|
clearPlayerSnapshotSocket(key);
|
|
console.error(error);
|
|
});
|
|
}
|
|
|
|
const dashboardStateService = createDashboardStateService({
|
|
pool: pool,
|
|
common: common,
|
|
thinClientBaseUrl: configuredThinClientBaseUrl,
|
|
playerSnapshotCache: playerSnapshotCache,
|
|
playerSnapshotSockets: playerSnapshotSockets,
|
|
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
|
|
formatDashboardDate: formatDashboardDate
|
|
});
|
|
const buildDashboardState = dashboardStateService.buildDashboardState;
|
|
|
|
const uploadSyncService = createUploadSyncService({
|
|
pool: pool,
|
|
common: common,
|
|
playerInternalBaseUrl: configuredPlayerInternalBaseUrl,
|
|
playerSnapshotCache: playerSnapshotCache,
|
|
notifyPlayerScreens: notifyPlayerScreens,
|
|
backgroundTaskQueue: backgroundTaskQueue
|
|
});
|
|
|
|
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
|
|
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
|
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
|
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
|
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
|
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
|
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
|
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
|
let lastDashboardState = null;
|
|
|
|
function getFallbackDashboardState() {
|
|
return lastDashboardState || {
|
|
playlists: [],
|
|
screens: [],
|
|
clients: [],
|
|
kioskPlayers: [],
|
|
slides: [],
|
|
playerServiceConnected: false,
|
|
connectedPlayersCount: 0,
|
|
connectedClientsCount: 0
|
|
};
|
|
}
|
|
|
|
async function resolveDashboardState() {
|
|
try {
|
|
const state = await buildDashboardState();
|
|
lastDashboardState = state;
|
|
return state;
|
|
} catch (error) {
|
|
if (lastDashboardState) {
|
|
console.error(error);
|
|
return lastDashboardState;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function sendDashboardStateToSocket(socket) {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
const state = await resolveDashboardState().catch(function (error) {
|
|
console.error(error);
|
|
return getFallbackDashboardState();
|
|
});
|
|
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
|
|
}
|
|
|
|
broadcastDashboardState = async function () {
|
|
if (dashboardRefreshInFlight) {
|
|
return dashboardRefreshInFlight;
|
|
}
|
|
|
|
dashboardRefreshInFlight = (async function () {
|
|
const state = await resolveDashboardState().catch(function (error) {
|
|
console.error(error);
|
|
return getFallbackDashboardState();
|
|
});
|
|
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/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,
|
|
uploadSyncService: uploadSyncService,
|
|
playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null,
|
|
buildDashboardState: buildDashboardState,
|
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
|
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
|
runMediaSyncTask: runMediaSyncTask,
|
|
broadcastDashboardState: broadcastDashboardState,
|
|
installDashboardWebsocket: installDashboardWebsocket
|
|
};
|
|
}
|
|
|
|
module.exports = { createWebBootstrap }; |