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:
+26
-579
@@ -1,151 +1,12 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const common = require('./common');
|
||||
|
||||
// Playlist assembly and revision helpers.
|
||||
async function buildScreenPlaylist(pool, slug) {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return { screen: null, playlist: null, slides: [] };
|
||||
}
|
||||
|
||||
const screen = screenRows[0];
|
||||
if (!screen.playlist_id) {
|
||||
return {
|
||||
screen,
|
||||
playlist: null,
|
||||
slides: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [])
|
||||
};
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY ps.position ASC, ps.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
|
||||
const templateIds = slideRows
|
||||
.filter(function (slide) { return slide.template_id; })
|
||||
.map(function (slide) { return slide.template_id; });
|
||||
const templatesById = {};
|
||||
let templateRows = [];
|
||||
let regionRows = [];
|
||||
if (templateIds.length) {
|
||||
[templateRows] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id IN (?)
|
||||
`, [templateIds]);
|
||||
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
templateRows.forEach(function (template) {
|
||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||
templatesById[template.id] = template;
|
||||
});
|
||||
}
|
||||
|
||||
const slides = slideRows.map(function (slide) {
|
||||
return {
|
||||
id: slide.id,
|
||||
title: slide.title,
|
||||
body: slide.body,
|
||||
duration_seconds: slide.duration_seconds,
|
||||
schedule_mode: slide.schedule_mode,
|
||||
schedule_start_datetime: slide.schedule_start_datetime,
|
||||
schedule_end_datetime: slide.schedule_end_datetime,
|
||||
schedule_start_time: slide.schedule_start_time,
|
||||
schedule_end_time: slide.schedule_end_time,
|
||||
schedule_days_json: slide.schedule_days_json,
|
||||
media_url: slide.media_path,
|
||||
media_type: slide.media_type,
|
||||
kind: common.mediaKind(slide.media_path),
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: common.parseJsonSafe(slide.content_json) || {}
|
||||
};
|
||||
});
|
||||
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
|
||||
|
||||
return { screen, playlist, slides, revision };
|
||||
}
|
||||
|
||||
function updatePlaylistRevisionHash(hash, value) {
|
||||
hash.update(String(value === null || value === undefined ? '' : value));
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
|
||||
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
||||
|
||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||
updatePlaylistRevisionHash(hash, slide.id);
|
||||
updatePlaylistRevisionHash(hash, slide.title);
|
||||
updatePlaylistRevisionHash(hash, slide.body);
|
||||
updatePlaylistRevisionHash(hash, slide.template_id);
|
||||
updatePlaylistRevisionHash(hash, slide.content_json);
|
||||
updatePlaylistRevisionHash(hash, slide.media_path);
|
||||
updatePlaylistRevisionHash(hash, slide.media_type);
|
||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
|
||||
});
|
||||
|
||||
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
||||
updatePlaylistRevisionHash(hash, template.id);
|
||||
updatePlaylistRevisionHash(hash, template.name);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_id);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_width);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_height);
|
||||
updatePlaylistRevisionHash(hash, template.background_image_path);
|
||||
updatePlaylistRevisionHash(hash, template.modified_at);
|
||||
});
|
||||
|
||||
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
|
||||
updatePlaylistRevisionHash(hash, region.id);
|
||||
updatePlaylistRevisionHash(hash, region.template_id);
|
||||
updatePlaylistRevisionHash(hash, region.region_key);
|
||||
updatePlaylistRevisionHash(hash, region.region_type);
|
||||
updatePlaylistRevisionHash(hash, region.label);
|
||||
updatePlaylistRevisionHash(hash, region.font_family);
|
||||
updatePlaylistRevisionHash(hash, region.x);
|
||||
updatePlaylistRevisionHash(hash, region.y);
|
||||
updatePlaylistRevisionHash(hash, region.width);
|
||||
updatePlaylistRevisionHash(hash, region.height);
|
||||
updatePlaylistRevisionHash(hash, region.z_index);
|
||||
updatePlaylistRevisionHash(hash, region.modified_at);
|
||||
});
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
const { createPlayerRuntime } = require('./player/runtime');
|
||||
const { createPlayerPlaylistService } = require('./player/playlist');
|
||||
const { normalizeDeviceId, registerPlayerOnboardingRoutes } = require('./player/onboarding');
|
||||
const { registerPlayerRoutes } = require('./player/routes');
|
||||
|
||||
|
||||
// Player runtime, upload API, and websocket wiring.
|
||||
@@ -155,327 +16,30 @@ async function start() {
|
||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
const playerRuntime = createPlayerRuntime({
|
||||
pool: pool,
|
||||
normalizeDeviceId: normalizeDeviceId
|
||||
});
|
||||
const playerPlaylistService = createPlayerPlaylistService({
|
||||
pool: pool,
|
||||
common: common
|
||||
});
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
playerRuntime.installWebsocket(server);
|
||||
app.use(express.json());
|
||||
|
||||
// Static assets and mirrored uploads are served from the player container.
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
app.get('/api/uploads/config', function (_req, res) {
|
||||
res.json({
|
||||
uploadDir: UPLOAD_DIR
|
||||
});
|
||||
registerPlayerOnboardingRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerRuntime: playerRuntime,
|
||||
QRCode: require('qrcode')
|
||||
});
|
||||
|
||||
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
|
||||
try {
|
||||
const filename = path.basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
await fs.promises.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
await fs.promises.writeFile(filePath, body);
|
||||
res.json({ ok: true, filename: filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/uploads/:filename', async function (req, res, next) {
|
||||
try {
|
||||
const filename = path.basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, filename: filename });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
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(slug);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(connectionId);
|
||||
if (!bucket.size) {
|
||||
connectionsBySlug.delete(slug);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 (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,
|
||||
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 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;
|
||||
}
|
||||
|
||||
app.get('/', function (_req, res) {
|
||||
res.send('Pulse Signage player service');
|
||||
});
|
||||
|
||||
// Screen playback endpoints render the active playlist for a slug.
|
||||
app.get('/screen/:slug', function (req, res) {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
buildScreenPlaylist(pool, req.params.slug).then(function (data) {
|
||||
res.send(common.renderPlayerPage(req.params.slug, data));
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
res.status(500).send('Internal server error');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
const data = await buildScreenPlaylist(pool, req.params.slug);
|
||||
if (!data.screen) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const etag = '"' + String(data.revision || '') + '"';
|
||||
res.set('ETag', etag);
|
||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).includes(etag)) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const connections = snapshotConnections(req.params.slug);
|
||||
res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: req.params.slug,
|
||||
count: connections.length,
|
||||
connections: connections
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/screens/:slug/commands', async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
|
||||
return res.status(400).json({ error: 'Unsupported command' });
|
||||
}
|
||||
|
||||
const isRedirectCommand = command === 'redirect';
|
||||
let screenRows = [];
|
||||
if (!isRedirectCommand) {
|
||||
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
}
|
||||
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: command;
|
||||
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
|
||||
commandPayload.blackout = blackoutValue;
|
||||
}
|
||||
|
||||
const sent = connectionId
|
||||
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: broadcastCommand(req.params.slug, commandPayload);
|
||||
|
||||
res.json({
|
||||
screen: screenRows[0] || null,
|
||||
screenSlug: req.params.slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
sent: sent
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
registerPlayerRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
uploadDir: UPLOAD_DIR,
|
||||
assetDir: ASSET_DIR,
|
||||
playerRuntime: playerRuntime,
|
||||
playerPlaylistService: playerPlaylistService
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
@@ -485,124 +49,6 @@ async function start() {
|
||||
|
||||
await common.ensureSchema(pool);
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
// Websocket upgrades split dashboard snapshots from player client sessions.
|
||||
server.on('upgrade', function (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 = String(request.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
const connectionId = crypto.randomUUID();
|
||||
const connection = {
|
||||
id: connectionId,
|
||||
slug: slug,
|
||||
socket: socket,
|
||||
clientId: null,
|
||||
userAgent: null,
|
||||
viewport: null,
|
||||
page: null,
|
||||
paused: false,
|
||||
blackout: false,
|
||||
clientIp: forwardedFor || remoteAddress,
|
||||
remoteAddress: remoteAddress,
|
||||
label: forwardedFor || remoteAddress || '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.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 ? String(payload.clientIp).trim() : 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);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
@@ -617,3 +63,4 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user