Files
MultiRoombaRover/server/src/services/interInstanceService/index.js
T

501 lines
17 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, getFeatureFlags, registerConfigurationHandler } = require('../../configuration');
const { getConfiguredSocials } = require('../sessionService/configuration');
const { getMode, MODES } = require('../modeManager');
const roverManager = require('../roverManager');
const { getTurnQueues, getActiveDrivers } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display');
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();
let interInstanceConfig = loadConfig().interInstance || {};
const interInstanceEvents = new EventEmitter();
const remoteInstances = new Map();
let pollGeneration = 0;
let pollingGeneration = null;
let pollTimer = null;
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(loadConfig().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();
const profileConfig = interInstanceConfig.profile || {};
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 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();
/*
Build every configuration-derived part of one public response from the
same immutable revision. Besides preventing a revision change from mixing
feature flags with newer social links, this supplies the explicit snapshot
required by getConfiguredSocials instead of relying on the removed legacy
global configuration object.
*/
const config = loadConfig();
const features = getFeatureFlags(config);
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 turnQueues = getTurnQueues();
const activeDrivers = getActiveDrivers();
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
return {
instance: {
...publicProfile(),
mode,
open: mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN,
features,
updatedAt: Date.now(),
},
// Only public roster entries reach the builder; queue metadata follows the
// same visibility boundary instead of creating separate orphan queue rows.
roster: roster.map((rover) => ({
...rover,
turn: buildRoverTurn({ rover, mode, socketId: null, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], users }),
})),
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 : [],
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 || [],
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(expectedGeneration = pollGeneration) {
if (!isEnabled() || expectedGeneration !== pollGeneration || pollingGeneration === expectedGeneration) return;
pollingGeneration = expectedGeneration;
try {
const entries = await fetchDirectoryEntries();
const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry)));
// Ignore responses from the previous directory/profile after a live edit;
// otherwise a slow retired request could repopulate peers after disable or
// overwrite results produced by the newly configured directory.
if (!isEnabled() || expectedGeneration !== pollGeneration) return;
replaceRemoteInstances(nextEntries);
interInstanceEvents.emit('change');
} catch (err) {
logger.warn('Inter-instance poll failed', { error: err.message });
} finally {
if (pollingGeneration === expectedGeneration) pollingGeneration = null;
}
}
function startPolling() {
pollGeneration += 1;
const expectedGeneration = pollGeneration;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (!isEnabled()) {
remoteInstances.clear();
interInstanceEvents.emit('change');
return;
}
pollNow(expectedGeneration);
pollTimer = setInterval(() => pollNow(expectedGeneration), pollIntervalMs());
pollTimer.unref?.();
}
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();
registerConfigurationHandler('interInstance', (nextConfig = {}) => {
// Replacing this single reference updates request timeouts, identity fields,
// directory URLs, and peer lists together. Rebuilding the interval applies
// the new cadence immediately and clears stale peers when disabled.
interInstanceConfig = nextConfig;
startPolling();
});
registerConfigurationHandler('publicUrl', () => {
/*
The canonical URL participates in self-filtering as well as the published
profile. Start a fresh generation immediately so results from an in-flight
poll using the former identity cannot be committed afterward.
*/
startPolling();
});
module.exports = {
getState,
interInstanceEvents,
buildLocalInfo,
pollNow,
};