mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
471 lines
16 KiB
JavaScript
471 lines
16 KiB
JavaScript
// Inter Instance Service
|
|
// Purpose: Publishes this server's public instance profile and polls public profiles from peer servers.
|
|
// Scope: Owns only the inter-instance directory/API contract; local control, auth, and rover state stay in their existing services.
|
|
const EventEmitter = require('events');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { app } = require('../../globals/http');
|
|
const io = require('../../globals/io');
|
|
const logger = require('../../globals/logger').child('interInstanceService');
|
|
const { loadConfig } = require('../../helpers/configLoader');
|
|
const { getFeatureFlags, getConfiguredSocials } = require('../../helpers/features');
|
|
const { getMode, MODES } = require('../modeManager');
|
|
const roverManager = require('../roverManager');
|
|
const { getTurnQueues } = require('../turnService');
|
|
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
|
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
|
const { getRole } = require('../roleService');
|
|
const { getNickname } = require('../nicknameService');
|
|
|
|
const DEFAULT_POLL_INTERVAL_MS = 30000;
|
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
|
const INFO_PATH = '/api/inter-instance/info';
|
|
const INSTANCE_ID = uuidv4();
|
|
const config = loadConfig();
|
|
const interInstanceConfig = config.interInstance || {};
|
|
const profileConfig = interInstanceConfig.profile || {};
|
|
const interInstanceEvents = new EventEmitter();
|
|
const remoteInstances = new Map();
|
|
|
|
let polling = false;
|
|
function asTrimmedString(value) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
const raw = asTrimmedString(value);
|
|
if (!raw) return '';
|
|
try {
|
|
const parsed = new URL(raw);
|
|
parsed.hash = '';
|
|
parsed.search = '';
|
|
return parsed.toString().replace(/\/$/, '');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function isEnabled() {
|
|
return Boolean(interInstanceConfig.enabled);
|
|
}
|
|
|
|
function requestTimeoutMs() {
|
|
const value = Number(interInstanceConfig.requestTimeoutMs);
|
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
}
|
|
|
|
function pollIntervalMs() {
|
|
const value = Number(interInstanceConfig.pollIntervalMs);
|
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_POLL_INTERVAL_MS;
|
|
}
|
|
|
|
function ownPublicUrl() {
|
|
return normalizeBaseUrl(profileConfig.publicUrl);
|
|
}
|
|
|
|
function ownInstanceId() {
|
|
/*
|
|
This id exists only for this Node process. That is enough to detect self
|
|
aliases during a poll cycle because every public URL that reaches this same
|
|
running server returns the same generated value.
|
|
*/
|
|
return INSTANCE_ID;
|
|
}
|
|
|
|
function buildPublicUrl(pathname) {
|
|
const base = ownPublicUrl();
|
|
if (!base || !pathname) return null;
|
|
return `${base}${pathname.startsWith('/') ? pathname : `/${pathname}`}`;
|
|
}
|
|
|
|
function publicProfile() {
|
|
const publicUrl = ownPublicUrl();
|
|
return {
|
|
id: ownInstanceId(),
|
|
name: asTrimmedString(profileConfig.name) || publicUrl || 'Rover server',
|
|
description: asTrimmedString(profileConfig.description),
|
|
color: asTrimmedString(profileConfig.color),
|
|
publicUrl,
|
|
};
|
|
}
|
|
|
|
function isLockdownMode() {
|
|
return getMode() === MODES.LOCKDOWN;
|
|
}
|
|
|
|
function buildUserEntry(socket) {
|
|
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
|
return {
|
|
socketId: socket.id,
|
|
userId: socket?.data?.userId || null,
|
|
nickname: getNickname(socket) || null,
|
|
role: getRole(socket),
|
|
roverId: primaryRover || null,
|
|
};
|
|
}
|
|
|
|
function addRoverSnapshotLinks(rover) {
|
|
const id = String(rover?.id || '').trim();
|
|
if (!id) return rover;
|
|
const state = getRoverSnapshotState(id);
|
|
const latestUrl = buildPublicUrl(`/api/inter-instance/rover-snapshots/${encodeURIComponent(id)}/latest`);
|
|
if (!latestUrl) return rover;
|
|
return {
|
|
...rover,
|
|
snapshots: {
|
|
latestUrl,
|
|
updatedAt: state?.ts || null,
|
|
error: state?.error || null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildRoomCameraInfo(camera) {
|
|
const state = getRoomCameraState(camera.id);
|
|
const snapshotUrl = buildPublicUrl(`/api/inter-instance/room-cameras/${encodeURIComponent(camera.id)}/snapshot`);
|
|
/*
|
|
Room camera config can point at private LAN URLs. The inter-instance payload
|
|
advertises this server's public snapshot endpoint instead, so remote clients
|
|
do not learn or depend on the local camera's internal address.
|
|
*/
|
|
return {
|
|
id: camera.id,
|
|
name: camera.name,
|
|
description: camera.description || null,
|
|
snapshotUrl,
|
|
updatedAt: state?.ts || null,
|
|
error: state?.error || null,
|
|
};
|
|
}
|
|
|
|
function isClosedPrivateRover(rover) {
|
|
return Boolean(rover?.private?.enabled && !rover?.private?.open);
|
|
}
|
|
|
|
function getPublicRoster() {
|
|
return roverManager
|
|
.getRoster()
|
|
/*
|
|
Closed private rovers are intentionally absent from the public
|
|
inter-instance contract. If a rover is private and closed, other servers
|
|
should not see its row or receive any derived snapshot URL for it.
|
|
*/
|
|
.filter((rover) => !isClosedPrivateRover(rover));
|
|
}
|
|
|
|
function publicRoverIdSet(roster = []) {
|
|
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
|
|
}
|
|
|
|
function filterPublicTurnQueues(turnQueues = {}, publicIds) {
|
|
const visible = publicIds instanceof Set ? publicIds : new Set();
|
|
const next = {};
|
|
/*
|
|
RoverQueuesPanel creates fallback rows for queue ids that are not present in
|
|
the roster, so the public payload must filter queues with the exact same
|
|
privacy boundary as the roster. Otherwise a closed-private rover can leak as
|
|
an orphan queue row after a private access grant assigns someone to it.
|
|
*/
|
|
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
|
|
if (!visible.has(String(roverId))) return;
|
|
next[roverId] = info;
|
|
});
|
|
return next;
|
|
}
|
|
|
|
function filterPublicUsers(users = [], publicIds) {
|
|
const visible = publicIds instanceof Set ? publicIds : new Set();
|
|
/*
|
|
A user's current rover id is also part of the public inter-instance surface.
|
|
If that rover is not in the public roster, scrub only that association while
|
|
leaving the rest of the public user entry intact for normal queue display.
|
|
*/
|
|
return users.map((user) => {
|
|
const roverId = user?.roverId ? String(user.roverId) : '';
|
|
if (!roverId || visible.has(roverId)) return user;
|
|
return { ...user, roverId: null };
|
|
});
|
|
}
|
|
|
|
function buildLocalInfo() {
|
|
const mode = getMode();
|
|
const lockdown = isLockdownMode();
|
|
const features = getFeatureFlags();
|
|
const publicRoster = getPublicRoster();
|
|
const publicIds = publicRoverIdSet(publicRoster);
|
|
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
|
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
|
|
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
|
return {
|
|
instance: {
|
|
...publicProfile(),
|
|
mode,
|
|
open: mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN,
|
|
features,
|
|
updatedAt: Date.now(),
|
|
},
|
|
roster,
|
|
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
|
|
users,
|
|
roomCameras,
|
|
socials: features.socials ? getConfiguredSocials(config) : [],
|
|
};
|
|
}
|
|
|
|
function sendJpegState(res, state, missingMessage) {
|
|
if (!state?.frame) {
|
|
res.status(404).json({ error: missingMessage });
|
|
return;
|
|
}
|
|
res.set('Cache-Control', 'no-store');
|
|
res.set('X-Rover-Snapshot-Ts', String(state.ts || ''));
|
|
res.type('jpeg').send(state.frame);
|
|
}
|
|
|
|
app.get(INFO_PATH, (req, res) => {
|
|
if (!isEnabled()) {
|
|
res.status(404).json({ error: 'Inter-instance sharing disabled' });
|
|
return;
|
|
}
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json(buildLocalInfo());
|
|
});
|
|
|
|
app.get('/api/inter-instance/rover-snapshots/:roverId/latest', (req, res) => {
|
|
if (!isEnabled() || isLockdownMode()) {
|
|
res.status(404).json({ error: 'Snapshot unavailable' });
|
|
return;
|
|
}
|
|
const roverId = String(req.params.roverId || '');
|
|
const publicRover = getPublicRoster().find((rover) => String(rover.id) === roverId);
|
|
if (!publicRover) {
|
|
/*
|
|
Do not rely on "not advertising the URL" as the privacy boundary. Public
|
|
snapshot hosting must also reject direct requests for closed-private or
|
|
unknown rovers because old URLs, logs, or guesses can outlive roster state.
|
|
*/
|
|
res.status(404).json({ error: 'Rover snapshot unavailable' });
|
|
return;
|
|
}
|
|
sendJpegState(res, getRoverSnapshotState(roverId), 'Rover snapshot unavailable');
|
|
});
|
|
|
|
app.get('/api/inter-instance/room-cameras/:cameraId/snapshot', (req, res) => {
|
|
if (!isEnabled() || isLockdownMode()) {
|
|
res.status(404).json({ error: 'Snapshot unavailable' });
|
|
return;
|
|
}
|
|
const cameraId = String(req.params.cameraId || '');
|
|
sendJpegState(res, getRoomCameraState(cameraId), 'Room camera snapshot unavailable');
|
|
});
|
|
|
|
function normalizeDirectoryEntry(entry) {
|
|
if (typeof entry === 'string') {
|
|
const url = normalizeBaseUrl(entry);
|
|
return url ? { url, name: '' } : null;
|
|
}
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
const url = normalizeBaseUrl(entry.url || entry.baseUrl || entry.publicUrl);
|
|
if (!url) return null;
|
|
return {
|
|
url,
|
|
name: asTrimmedString(entry.name),
|
|
};
|
|
}
|
|
|
|
function uniqueDirectoryEntries(entries) {
|
|
const seen = new Set();
|
|
return entries.filter((entry) => {
|
|
if (!entry?.url || seen.has(entry.url)) return false;
|
|
seen.add(entry.url);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), requestTimeoutMs());
|
|
try {
|
|
const res = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return await res.json();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function fetchDirectoryEntries() {
|
|
const urls = Array.isArray(interInstanceConfig.directoryUrls)
|
|
? interInstanceConfig.directoryUrls.map((url) => asTrimmedString(url)).filter(Boolean)
|
|
: [];
|
|
const lists = await Promise.allSettled(urls.map((url) => fetchJson(url)));
|
|
const entries = [];
|
|
lists.forEach((result, idx) => {
|
|
if (result.status !== 'fulfilled') {
|
|
logger.warn('Directory fetch failed', { url: urls[idx], error: result.reason?.message || String(result.reason) });
|
|
return;
|
|
}
|
|
if (!Array.isArray(result.value)) {
|
|
logger.warn('Directory response was not an array', { url: urls[idx] });
|
|
return;
|
|
}
|
|
result.value.forEach((entry) => {
|
|
const normalized = normalizeDirectoryEntry(entry);
|
|
if (normalized) entries.push(normalized);
|
|
});
|
|
});
|
|
const self = ownPublicUrl();
|
|
return uniqueDirectoryEntries(entries).filter((entry) => entry.url !== self);
|
|
}
|
|
|
|
function normalizeRemotePayload(entry, payload) {
|
|
const instance = payload?.instance && typeof payload.instance === 'object' ? payload.instance : {};
|
|
/*
|
|
Remote payloads are intentionally additive. Every read below has a passive
|
|
fallback so older or partially configured servers still produce a useful
|
|
listing instead of breaking the whole directory view.
|
|
*/
|
|
return {
|
|
url: entry.url,
|
|
online: true,
|
|
lastSuccessAt: Date.now(),
|
|
lastError: null,
|
|
latencyMs: null,
|
|
instance: {
|
|
...instance,
|
|
id: asTrimmedString(instance.id),
|
|
name: asTrimmedString(instance.name) || entry.name || entry.url,
|
|
publicUrl: normalizeBaseUrl(instance.publicUrl) || entry.url,
|
|
description: asTrimmedString(instance.description),
|
|
color: asTrimmedString(instance.color),
|
|
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
|
},
|
|
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
|
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
|
|
users: Array.isArray(payload?.users) ? payload.users : [],
|
|
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
|
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
|
};
|
|
}
|
|
|
|
function remoteIdentityKey(remote) {
|
|
const advertisedId = asTrimmedString(remote?.instance?.id);
|
|
if (advertisedId) return `id:${advertisedId}`;
|
|
const advertisedPublicUrl = normalizeBaseUrl(remote?.instance?.publicUrl);
|
|
if (advertisedPublicUrl) return `url:${advertisedPublicUrl}`;
|
|
return `url:${normalizeBaseUrl(remote?.url) || remote?.url || ''}`;
|
|
}
|
|
|
|
function isSelfRemote(remote) {
|
|
const ownId = ownInstanceId();
|
|
const remoteId = asTrimmedString(remote?.instance?.id);
|
|
if (ownId && remoteId && ownId === remoteId) return true;
|
|
const self = ownPublicUrl();
|
|
const remotePublicUrl = normalizeBaseUrl(remote?.instance?.publicUrl);
|
|
const remoteUrl = normalizeBaseUrl(remote?.url);
|
|
return Boolean(self && (remotePublicUrl === self || remoteUrl === self));
|
|
}
|
|
|
|
function preferRemoteEntry(current, candidate) {
|
|
/*
|
|
When the directory has multiple URLs for one instance, keep the healthier
|
|
entry. Online data beats offline placeholders, and lower latency is a useful
|
|
tiebreaker when two aliases both work.
|
|
*/
|
|
if (!current) return candidate;
|
|
if (candidate.online && !current.online) return candidate;
|
|
if (!candidate.online && current.online) return current;
|
|
if (candidate.online && current.online) {
|
|
const currentLatency = Number.isFinite(current.latencyMs) ? current.latencyMs : Infinity;
|
|
const candidateLatency = Number.isFinite(candidate.latencyMs) ? candidate.latencyMs : Infinity;
|
|
return candidateLatency < currentLatency ? candidate : current;
|
|
}
|
|
const currentName = asTrimmedString(current?.instance?.name);
|
|
const candidateName = asTrimmedString(candidate?.instance?.name);
|
|
return !currentName && candidateName ? candidate : current;
|
|
}
|
|
|
|
function replaceRemoteInstances(nextEntries) {
|
|
const deduped = new Map();
|
|
nextEntries.forEach((entry) => {
|
|
if (!entry || isSelfRemote(entry)) return;
|
|
const key = remoteIdentityKey(entry);
|
|
deduped.set(key, preferRemoteEntry(deduped.get(key), entry));
|
|
});
|
|
remoteInstances.clear();
|
|
Array.from(deduped.values()).forEach((entry) => {
|
|
remoteInstances.set(remoteIdentityKey(entry), entry);
|
|
});
|
|
}
|
|
|
|
function markOffline(entry, error) {
|
|
const previous = remoteInstances.get(`url:${entry.url}`) || {};
|
|
return {
|
|
...previous,
|
|
url: entry.url,
|
|
online: false,
|
|
lastError: error?.message || String(error || 'Unknown error'),
|
|
instance: {
|
|
...(previous.instance || {}),
|
|
name: previous.instance?.name || entry.name || entry.url,
|
|
publicUrl: previous.instance?.publicUrl || entry.url,
|
|
},
|
|
roster: previous.roster || [],
|
|
turnQueues: previous.turnQueues || {},
|
|
users: previous.users || [],
|
|
roomCameras: previous.roomCameras || [],
|
|
socials: previous.socials || [],
|
|
};
|
|
}
|
|
|
|
async function pollRemoteInstance(entry) {
|
|
const start = Date.now();
|
|
try {
|
|
const payload = await fetchJson(`${entry.url}${INFO_PATH}`);
|
|
const normalized = normalizeRemotePayload(entry, payload);
|
|
normalized.latencyMs = Date.now() - start;
|
|
return normalized;
|
|
} catch (err) {
|
|
return markOffline(entry, err);
|
|
}
|
|
}
|
|
|
|
async function pollNow() {
|
|
if (!isEnabled() || polling) return;
|
|
polling = true;
|
|
try {
|
|
const entries = await fetchDirectoryEntries();
|
|
const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry)));
|
|
replaceRemoteInstances(nextEntries);
|
|
interInstanceEvents.emit('change');
|
|
} catch (err) {
|
|
logger.warn('Inter-instance poll failed', { error: err.message });
|
|
} finally {
|
|
polling = false;
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (!isEnabled()) return;
|
|
pollNow();
|
|
setInterval(pollNow, pollIntervalMs());
|
|
}
|
|
|
|
function getState() {
|
|
return {
|
|
enabled: isEnabled(),
|
|
profile: publicProfile(),
|
|
instances: Array.from(remoteInstances.values()).sort((a, b) =>
|
|
String(a.instance?.name || a.url).localeCompare(String(b.instance?.name || b.url)),
|
|
),
|
|
};
|
|
}
|
|
|
|
startPolling();
|
|
|
|
module.exports = {
|
|
getState,
|
|
interInstanceEvents,
|
|
buildLocalInfo,
|
|
pollNow,
|
|
};
|