Compare commits
3 Commits
fix/1535
...
fix/1536-h
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c9f4310d0 | |||
| 15b9a4398c | |||
|
|
11175e72c0 |
3
app.js
3
app.js
@@ -734,6 +734,9 @@ async function init() {
|
||||
const response = await fetch('./portals.json');
|
||||
const portalData = await response.json();
|
||||
createPortals(portalData);
|
||||
|
||||
// Start portal hot-reload watcher
|
||||
if (window.PortalHotReload) PortalHotReload.start(5000);
|
||||
} catch (e) {
|
||||
console.error('Failed to load portals.json:', e);
|
||||
addChatMessage('error', 'Portal registry offline. Check logs.');
|
||||
|
||||
@@ -395,9 +395,9 @@
|
||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
||||
|
||||
<script src="./boot.js"></script>
|
||||
<script src="./js/heartbeat.js"></script>
|
||||
<script src="./avatar-customization.js"></script>
|
||||
<script src="./lod-system.js"></script>
|
||||
<script src="./portal-hot-reload.js"></script>
|
||||
<script>
|
||||
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
|
||||
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }
|
||||
|
||||
293
js/heartbeat.js
293
js/heartbeat.js
@@ -1,293 +0,0 @@
|
||||
/**
|
||||
* WebSocket Heartbeat Client for The Nexus
|
||||
* Issue #1535: feat: WebSocket heartbeat with auto-reconnect from client
|
||||
*
|
||||
* Provides:
|
||||
* - Client sends heartbeat ping every 30s
|
||||
* - Server responds with pong + user count
|
||||
* - Client auto-reconnects on missed 2 heartbeats
|
||||
* - Reconnect preserves user position/identity
|
||||
*/
|
||||
|
||||
class NexusHeartbeat {
|
||||
constructor(options = {}) {
|
||||
this.heartbeatInterval = options.heartbeatInterval || 30000; // 30 seconds
|
||||
this.maxMissedHeartbeats = options.maxMissedHeartbeats || 2;
|
||||
this.reconnectDelay = options.reconnectDelay || 1000; // 1 second
|
||||
this.maxReconnectDelay = options.maxReconnectDelay || 30000; // 30 seconds
|
||||
|
||||
this.ws = null;
|
||||
this.heartbeatTimer = null;
|
||||
this.missedHeartbeats = 0;
|
||||
this.isConnected = false;
|
||||
this.userId = options.userId || this.generateUserId();
|
||||
this.position = options.position || { x: 0, y: 0, z: 0 };
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Callbacks
|
||||
this.onConnect = options.onConnect || (() => {});
|
||||
this.onDisconnect = options.onDisconnect || (() => {});
|
||||
this.onHeartbeat = options.onHeartbeat || (() => {});
|
||||
this.onUserCount = options.onUserCount || (() => {});
|
||||
this.onError = options.onError || console.error;
|
||||
|
||||
// Bind methods
|
||||
this.connect = this.connect.bind(this);
|
||||
this.disconnect = this.disconnect.bind(this);
|
||||
this.sendHeartbeat = this.sendHeartbeat.bind(this);
|
||||
this.handleMessage = this.handleMessage.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleError = this.handleError.bind(this);
|
||||
}
|
||||
|
||||
generateUserId() {
|
||||
return 'user_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
connect(url) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
console.warn('Already connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.url = url;
|
||||
console.log(`Connecting to ${url}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = this.handleOpen.bind(this);
|
||||
this.ws.onmessage = this.handleMessage;
|
||||
this.ws.onclose = this.handleClose;
|
||||
this.ws.onerror = this.handleError;
|
||||
} catch (error) {
|
||||
this.onError('Failed to create WebSocket:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
console.log('Disconnecting...');
|
||||
|
||||
// Stop heartbeat
|
||||
this.stopHeartbeat();
|
||||
|
||||
// Close WebSocket
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // Prevent reconnect on manual disconnect
|
||||
this.ws.close(1000, 'Manual disconnect');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isConnected = false;
|
||||
this.missedHeartbeats = 0;
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
console.log('Connected to WebSocket');
|
||||
this.isConnected = true;
|
||||
this.missedHeartbeats = 0;
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Send reconnect message with user info
|
||||
this.sendReconnect();
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat();
|
||||
|
||||
// Call connect callback
|
||||
this.onConnect();
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'pong') {
|
||||
// Reset missed heartbeats
|
||||
this.missedHeartbeats = 0;
|
||||
|
||||
// Update user count
|
||||
if (data.user_count !== undefined) {
|
||||
this.onUserCount(data.user_count);
|
||||
}
|
||||
|
||||
// Call heartbeat callback
|
||||
this.onHeartbeat(data);
|
||||
|
||||
console.debug('Heartbeat pong received');
|
||||
} else if (data.type === 'health') {
|
||||
// Health check response
|
||||
console.debug('Health check:', data);
|
||||
} else {
|
||||
// Regular message
|
||||
console.debug('Message received:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Not JSON or parse error
|
||||
console.debug('Non-JSON message received:', event.data);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose(event) {
|
||||
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
|
||||
this.isConnected = false;
|
||||
this.stopHeartbeat();
|
||||
|
||||
// Call disconnect callback
|
||||
this.onDisconnect(event);
|
||||
|
||||
// Schedule reconnect if not manual disconnect
|
||||
if (event.code !== 1000) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error) {
|
||||
this.onError('WebSocket error:', error);
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
}
|
||||
|
||||
console.log(`Starting heartbeat every ${this.heartbeatInterval / 1000}s`);
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.sendHeartbeat();
|
||||
}, this.heartbeatInterval);
|
||||
|
||||
// Send initial heartbeat
|
||||
this.sendHeartbeat();
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
sendHeartbeat() {
|
||||
if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('Cannot send heartbeat: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const heartbeat = {
|
||||
type: 'heartbeat',
|
||||
timestamp: Date.now(),
|
||||
user_id: this.userId,
|
||||
position: this.position
|
||||
};
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(heartbeat));
|
||||
console.debug('Heartbeat sent');
|
||||
|
||||
// Check for missed heartbeats
|
||||
this.missedHeartbeats++;
|
||||
if (this.missedHeartbeats > this.maxMissedHeartbeats) {
|
||||
console.warn(`Missed ${this.missedHeartbeats} heartbeats, reconnecting...`);
|
||||
this.ws.close(4000, 'Missed heartbeats');
|
||||
}
|
||||
} catch (error) {
|
||||
this.onError('Failed to send heartbeat:', error);
|
||||
this.ws.close(4001, 'Heartbeat send failed');
|
||||
}
|
||||
}
|
||||
|
||||
sendReconnect() {
|
||||
if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('Cannot send reconnect: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const reconnect = {
|
||||
type: 'reconnect',
|
||||
timestamp: Date.now(),
|
||||
user_id: this.userId,
|
||||
position: this.position
|
||||
};
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(reconnect));
|
||||
console.log('Reconnect message sent');
|
||||
} catch (error) {
|
||||
this.onError('Failed to send reconnect:', error);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= 10) {
|
||||
console.error('Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
console.log(`Reconnecting in ${delay / 1000}s (attempt ${this.reconnectAttempts + 1})...`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect(this.url);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
updatePosition(x, y, z) {
|
||||
this.position = { x, y, z };
|
||||
|
||||
// Send position update if connected
|
||||
if (this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const update = {
|
||||
type: 'position',
|
||||
timestamp: Date.now(),
|
||||
user_id: this.userId,
|
||||
position: this.position
|
||||
};
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(update));
|
||||
} catch (error) {
|
||||
console.warn('Failed to send position update:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return { ...this.position };
|
||||
}
|
||||
|
||||
isConnectionActive() {
|
||||
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
connected: this.isConnected,
|
||||
userId: this.userId,
|
||||
position: this.position,
|
||||
missedHeartbeats: this.missedHeartbeats,
|
||||
reconnectAttempts: this.reconnectAttempts
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = NexusHeartbeat;
|
||||
}
|
||||
|
||||
// Global instance for browser use
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NexusHeartbeat = NexusHeartbeat;
|
||||
}
|
||||
105
portal-hot-reload.js
Normal file
105
portal-hot-reload.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Portal Hot-Reload for The Nexus
|
||||
*
|
||||
* Watches portals.json for changes and hot-reloads portal list
|
||||
* without server restart. Existing connections unaffected.
|
||||
*
|
||||
* Usage:
|
||||
* PortalHotReload.start(intervalMs);
|
||||
* PortalHotReload.stop();
|
||||
* PortalHotReload.reload(); // manual reload
|
||||
*/
|
||||
|
||||
const PortalHotReload = (() => {
|
||||
let _interval = null;
|
||||
let _lastHash = '';
|
||||
let _pollInterval = 5000; // 5 seconds
|
||||
|
||||
function _hashPortals(data) {
|
||||
// Simple hash of portal IDs for change detection
|
||||
return data.map(p => p.id || p.name).sort().join(',');
|
||||
}
|
||||
|
||||
async function _checkForChanges() {
|
||||
try {
|
||||
const response = await fetch('./portals.json?t=' + Date.now());
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const hash = _hashPortals(data);
|
||||
|
||||
if (hash !== _lastHash) {
|
||||
console.log('[PortalHotReload] Detected change — reloading portals');
|
||||
_lastHash = hash;
|
||||
_reloadPortals(data);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail — file might be mid-write
|
||||
}
|
||||
}
|
||||
|
||||
function _reloadPortals(data) {
|
||||
// Remove old portals from scene
|
||||
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
|
||||
portals.forEach(p => {
|
||||
if (p.group && typeof scene !== 'undefined' && scene) {
|
||||
scene.remove(p.group);
|
||||
}
|
||||
});
|
||||
portals.length = 0;
|
||||
}
|
||||
|
||||
// Create new portals
|
||||
if (typeof createPortals === 'function') {
|
||||
createPortals(data);
|
||||
}
|
||||
|
||||
// Re-register with spatial search if available
|
||||
if (window.SpatialSearch && typeof portals !== 'undefined') {
|
||||
portals.forEach(p => {
|
||||
if (p.config && p.config.name && p.group) {
|
||||
SpatialSearch.register('portal', p, p.config.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notify
|
||||
if (typeof addChatMessage === 'function') {
|
||||
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
|
||||
}
|
||||
|
||||
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
|
||||
}
|
||||
|
||||
function start(intervalMs) {
|
||||
if (_interval) return;
|
||||
_pollInterval = intervalMs || _pollInterval;
|
||||
|
||||
// Initial load
|
||||
fetch('./portals.json').then(r => r.json()).then(data => {
|
||||
_lastHash = _hashPortals(data);
|
||||
}).catch(() => {});
|
||||
|
||||
_interval = setInterval(_checkForChanges, _pollInterval);
|
||||
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (_interval) {
|
||||
clearInterval(_interval);
|
||||
_interval = null;
|
||||
console.log('[PortalHotReload] Stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const response = await fetch('./portals.json?t=' + Date.now());
|
||||
const data = await response.json();
|
||||
_lastHash = _hashPortals(data);
|
||||
_reloadPortals(data);
|
||||
}
|
||||
|
||||
return { start, stop, reload };
|
||||
})();
|
||||
|
||||
window.PortalHotReload = PortalHotReload;
|
||||
Reference in New Issue
Block a user