Compare commits
3 Commits
mimo/code/
...
fix/1535
| Author | SHA1 | Date | |
|---|---|---|---|
| 2664ad40cc | |||
| 274445a2b3 | |||
|
|
f6e206084b |
@@ -395,6 +395,7 @@
|
|||||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
<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="./boot.js"></script>
|
||||||
|
<script src="./js/heartbeat.js"></script>
|
||||||
<script src="./avatar-customization.js"></script>
|
<script src="./avatar-customization.js"></script>
|
||||||
<script src="./lod-system.js"></script>
|
<script src="./lod-system.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
293
js/heartbeat.js
Normal file
293
js/heartbeat.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user