Bump version to 1.4.1 and tighten client handling

This commit is contained in:
2026-07-21 01:20:12 +01:00
parent 2ea8d389fa
commit 8393923c5a
15 changed files with 440 additions and 85 deletions
+98
View File
@@ -0,0 +1,98 @@
const fs = require('fs');
const path = require('path');
function isTransientDbError(error) {
const code = String(error && error.code ? error.code : '').trim();
return [
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EPIPE',
'ENOTFOUND',
'PROTOCOL_CONNECTION_LOST',
'POOL_CLOSED',
'ERR_POOL_CLOSED'
].indexOf(code) !== -1;
}
function createOnboardingStore(filePath) {
const normalizedFilePath = String(filePath || '').trim();
async function readEntries() {
try {
const raw = await fs.promises.readFile(normalizedFilePath, 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
if (error && error.code === 'ENOENT') {
return [];
}
throw error;
}
}
async function writeEntries(entries) {
await fs.promises.mkdir(path.dirname(normalizedFilePath), { recursive: true });
const tempPath = `${normalizedFilePath}.tmp`;
await fs.promises.writeFile(tempPath, JSON.stringify(Array.isArray(entries) ? entries : [], null, 2), 'utf8');
await fs.promises.rename(tempPath, normalizedFilePath);
}
async function enqueueBinding(entry) {
const normalizedEntry = {
deviceId: String(entry && entry.deviceId ? entry.deviceId : '').trim(),
clientName: String(entry && entry.clientName ? entry.clientName : '').trim(),
screenSlug: String(entry && entry.screenSlug ? entry.screenSlug : '').trim(),
queuedAt: String(entry && entry.queuedAt ? entry.queuedAt : new Date().toISOString())
};
if (!normalizedEntry.deviceId || !normalizedEntry.clientName || !normalizedEntry.screenSlug) {
return readEntries();
}
const entries = await readEntries();
const nextEntries = entries.filter(function (queuedEntry) {
return String(queuedEntry && queuedEntry.deviceId ? queuedEntry.deviceId : '').trim() !== normalizedEntry.deviceId;
});
nextEntries.push(normalizedEntry);
await writeEntries(nextEntries);
return nextEntries;
}
async function flushBindings(applyBinding) {
const entries = await readEntries();
if (!entries.length) {
return { flushed: 0, remaining: 0 };
}
const remaining = [];
let flushed = 0;
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
try {
await applyBinding(entry);
flushed += 1;
} catch (error) {
if (isTransientDbError(error)) {
remaining.push.apply(remaining, entries.slice(index));
break;
}
remaining.push.apply(remaining, entries.slice(index + 1));
}
}
await writeEntries(remaining);
return { flushed: flushed, remaining: remaining.length };
}
return {
enqueueBinding: enqueueBinding,
flushBindings: flushBindings,
readEntries: readEntries
};
}
module.exports = {
createOnboardingStore: createOnboardingStore,
isTransientDbError: isTransientDbError
};
+43 -8
View File
@@ -1,3 +1,5 @@
const { isClientNameAvailable } = require('../client-name-check');
const { isTransientDbError } = require('./onboarding-store');
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
@@ -31,7 +33,7 @@ async function getOnboardingStatus(pool, deviceId) {
return rows[0] || null;
}
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen) {
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedClientName = String(clientName || '').trim();
const normalizedScreenSlug = String(screenSlug || '').trim();
@@ -52,8 +54,8 @@ async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isName
}
const screen = screenRows[0];
const available = typeof isNameAvailableOnScreen === 'function'
? await isNameAvailableOnScreen(pool, normalizedClientName, normalizedDeviceId)
const available = typeof isClientNameAvailableOnScreen === 'function'
? await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections)
: true;
if (!available) {
const error = new Error('Client name already exists.');
@@ -69,11 +71,42 @@ async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isName
return getOnboardingStatus(pool, normalizedDeviceId);
}
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
? playerRuntime.snapshotAllConnections()
: [];
try {
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
} catch (error) {
if (!isTransientDbError(error)) {
throw error;
}
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
await onboardingStore.enqueueBinding({
deviceId: deviceId,
clientName: clientName,
screenSlug: screenSlug,
queuedAt: new Date().toISOString()
});
}
return {
device_id: normalizeDeviceId(deviceId),
client_name: String(clientName || '').trim(),
screen_slug: String(screenSlug || '').trim(),
queued: true
};
}
}
function registerPlayerOnboardingRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const QRCode = options && options.QRCode ? options.QRCode : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
if (!app || !pool || !common || !playerRuntime || !QRCode) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
@@ -146,14 +179,15 @@ function registerPlayerOnboardingRoutes(app, options) {
return res.status(400).json({ error: 'Screen is required' });
}
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen);
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (error) {
next(error);
@@ -165,6 +199,7 @@ module.exports = {
normalizeDeviceId: normalizeDeviceId,
getPublicBaseUrl: getPublicBaseUrl,
getOnboardingStatus: getOnboardingStatus,
commitDeviceBinding: commitDeviceBinding,
bindDeviceToScreen: bindDeviceToScreen,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};
+2 -2
View File
@@ -138,8 +138,8 @@ function registerPlayerRoutes(app, options) {
}
const sent = connectionId
? playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: playerRuntime.broadcastCommand(req.params.slug, commandPayload);
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
res.json({
screen: screenRows[0] || null,
+16 -64
View File
@@ -1,5 +1,6 @@
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../client-name-check');
function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null;
@@ -134,76 +135,25 @@ function createPlayerRuntime(options) {
});
}
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();
function snapshotAllConnections() {
const allConnections = [];
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;
allConnections.push({
clientId: connection.clientId || null,
clientName: connection.clientName || null,
deviceId: connection.deviceId || null
});
}
}
return allConnections;
}
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;
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
}
function broadcastConnectionSnapshot(slug) {
@@ -227,7 +177,7 @@ function createPlayerRuntime(options) {
});
}
function sendCommandToConnection(slug, connectionId, commandOrPayload) {
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
return 0;
@@ -249,7 +199,7 @@ function createPlayerRuntime(options) {
return 1;
}
function broadcastCommand(slug, commandOrPayload) {
async function broadcastCommand(slug, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
return 0;
@@ -269,6 +219,7 @@ function createPlayerRuntime(options) {
connection.socket.send(JSON.stringify(payload));
sent += 1;
});
return sent;
}
@@ -405,6 +356,7 @@ function createPlayerRuntime(options) {
return {
installWebsocket: installWebsocket,
snapshotConnections: snapshotConnections,
snapshotAllConnections: snapshotAllConnections,
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
sendCommandToConnection: sendCommandToConnection,
broadcastCommand: broadcastCommand