Compare commits
3 Commits
fix/1535
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f9e3d5bdb | |||
| 6b21fbc88a | |||
|
|
ebf81455db |
@@ -395,7 +395,6 @@
|
||||
<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
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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated_at": "2026-04-11T01:14:54.632326+00:00",
|
||||
"generated_at": "2026-04-21T11:25:52.332806+00:00",
|
||||
"repo": "Timmy_Foundation/the-nexus",
|
||||
"git": {
|
||||
"commit": "d408d2c365a9efc0c1e3a9b38b9cc4eed75695c5",
|
||||
@@ -9,28 +9,28 @@
|
||||
},
|
||||
"files": {
|
||||
"index.html": {
|
||||
"sha256": "71ba27afe8b6b42a09efe09d2b3017599392ddc3bc02543b31c2277dfb0b82cc",
|
||||
"size": 25933
|
||||
"sha256": "688938028c0fae734fddae8527bcfb7acd1702cab1fc65f6fcef5b066596eab3",
|
||||
"size": 21079
|
||||
},
|
||||
"app.js": {
|
||||
"sha256": "2b765a724a0fcda29abd40ba921bc621d2699f11d0ba14cf1579cbbdafdc5cd5",
|
||||
"size": 132902
|
||||
"sha256": "2ab33d147ceec755e5ee7d3cc35ae65593cd238b2b2d1d9e73af5227ba33dfe2",
|
||||
"size": 141038
|
||||
},
|
||||
"style.css": {
|
||||
"sha256": "cd3068d03eed6f52a00bbc32cfae8fba4739b8b3cb194b3ec09fd747a075056d",
|
||||
"size": 44198
|
||||
"sha256": "a7228e516f8210bac580a1caa2f6223ec9ec533e46c58b585a7cbc53bc047fba",
|
||||
"size": 60727
|
||||
},
|
||||
"gofai_worker.js": {
|
||||
"sha256": "d292f110aa12a8aa2b16b0c2d48e5b4ce24ee15b1cffb409ab846b1a05a91de2",
|
||||
"size": 969
|
||||
"sha256": "01d1444b1e4c899a7579aa4e5624d5a0683e10b54a924005a7003534c607a500",
|
||||
"size": 1925
|
||||
},
|
||||
"server.py": {
|
||||
"sha256": "e963cc9715accfc8814e3fe5c44af836185d66740d5a65fd0365e9c629d38e05",
|
||||
"size": 4185
|
||||
"sha256": "79292dfd6955020f5d8ae368e8ce61a4831255dc1a935cbf8f51b35eaa2e4498",
|
||||
"size": 4389
|
||||
},
|
||||
"portals.json": {
|
||||
"sha256": "889a5e0f724eb73a95f960bca44bca232150bddff7c1b11f253bd056f3683a08",
|
||||
"size": 3442
|
||||
"sha256": "82f91c3b8707d197e6295e594e616e92f9e215399a7181a130874e23381efa9f",
|
||||
"size": 6399
|
||||
},
|
||||
"vision.json": {
|
||||
"sha256": "0e3b5c06af98486bbcb2fc2dc627dc8b7b08aed4c3a4f9e10b57f91e1e8ca6ad",
|
||||
@@ -41,8 +41,8 @@
|
||||
"size": 495
|
||||
},
|
||||
"nexus/components/spatial-memory.js": {
|
||||
"sha256": "60170f6490ddd743acd6d285d3a1af6cad61fbf8aaef3f679ff4049108eac160",
|
||||
"size": 32782
|
||||
"sha256": "0945845828b3cfaac6060050bf138463b5108b6c135e4c396751da92a7376534",
|
||||
"size": 38272
|
||||
},
|
||||
"nexus/components/session-rooms.js": {
|
||||
"sha256": "9997a60dda256e38cb4645508bf9e98c15c3d963b696e0080e3170a9a7fa7cf1",
|
||||
@@ -59,4 +59,4 @@
|
||||
},
|
||||
"missing": [],
|
||||
"file_count": 12
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user