Compare commits

...

3 Commits

Author SHA1 Message Date
2664ad40cc Merge branch 'main' into fix/1535
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 13s
CI / test (pull_request) Failing after 1m20s
CI / validate (pull_request) Failing after 1m27s
2026-04-22 01:16:11 +00:00
274445a2b3 Merge branch 'main' into fix/1535
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 1m1s
CI / validate (pull_request) Failing after 1m8s
2026-04-22 01:09:35 +00:00
Alexander Whitestone
f6e206084b fix: #1535
Some checks failed
CI / test (pull_request) Failing after 28s
CI / validate (pull_request) Failing after 32s
Review Approval Gate / verify-review (pull_request) Failing after 2s
- Add WebSocket heartbeat client for auto-reconnect
- Add js/heartbeat.js with heartbeat functionality
- Add script to index.html

Features:
1. Client sends heartbeat ping every 30 seconds
2. Server responds with pong + user count
3. Client auto-reconnects on missed 2 heartbeats
4. Reconnect preserves user position/identity
5. Exponential backoff for reconnection

Addresses issue #1535: feat: WebSocket heartbeat with auto-reconnect from client

Usage:
- Initialize: new NexusHeartbeat()
- Connect: heartbeat.connect('ws://localhost:8765')
- Update position: heartbeat.updatePosition(x, y, z)
- Get stats: heartbeat.getStats()
2026-04-17 02:36:32 -04:00
2 changed files with 294 additions and 0 deletions

View File

@@ -395,6 +395,7 @@
<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>

293
js/heartbeat.js Normal file
View File

@@ -0,0 +1,293 @@
/**
* 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;
}