Compare commits
1 Commits
mimo/code/
...
fix/1623
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0f40c7a92 |
@@ -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/portal-registry-watcher.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>
|
||||||
|
|||||||
280
js/portal-registry-watcher.js
Normal file
280
js/portal-registry-watcher.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Portal Registry Watcher
|
||||||
|
* Issue #1623: feat: portal hot-reload from portals.json without server restart
|
||||||
|
*
|
||||||
|
* Watches portals.json for changes and hot-reloads portal scene objects
|
||||||
|
* without requiring a server restart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class PortalRegistryWatcher {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.pollInterval = options.pollInterval || 5000; // 5 seconds
|
||||||
|
this.registryPath = options.registryPath || './portals.json';
|
||||||
|
this.onRegistryUpdate = options.onRegistryUpdate || (() => {});
|
||||||
|
this.onError = options.onError || console.error;
|
||||||
|
|
||||||
|
this.pollTimer = null;
|
||||||
|
this.lastRegistry = null;
|
||||||
|
this.lastModified = null;
|
||||||
|
this.isWatching = false;
|
||||||
|
|
||||||
|
// Cache-busting timestamp
|
||||||
|
this._registryTs = Date.now();
|
||||||
|
|
||||||
|
// Bind methods
|
||||||
|
this.startWatching = this.startWatching.bind(this);
|
||||||
|
this.stopWatching = this.stopWatching.bind(this);
|
||||||
|
this.poll = this.poll.bind(this);
|
||||||
|
this.loadRegistry = this.loadRegistry.bind(this);
|
||||||
|
this.applyRegistry = this.applyRegistry.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching for registry changes
|
||||||
|
*/
|
||||||
|
startWatching() {
|
||||||
|
if (this.isWatching) {
|
||||||
|
console.warn('[PortalRegistry] Already watching');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PortalRegistry] Starting to watch ${this.registryPath} every ${this.pollInterval / 1000}s`);
|
||||||
|
|
||||||
|
this.isWatching = true;
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
this.poll();
|
||||||
|
|
||||||
|
// Set up interval
|
||||||
|
this.pollTimer = setInterval(this.poll, this.pollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop watching
|
||||||
|
*/
|
||||||
|
stopWatching() {
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer);
|
||||||
|
this.pollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isWatching = false;
|
||||||
|
console.log('[PortalRegistry] Stopped watching');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for registry changes
|
||||||
|
*/
|
||||||
|
async poll() {
|
||||||
|
try {
|
||||||
|
const registry = await this.loadRegistry();
|
||||||
|
|
||||||
|
if (this.hasChanged(registry)) {
|
||||||
|
console.log('[PortalRegistry] Registry changed, applying updates...');
|
||||||
|
this.applyRegistry(registry);
|
||||||
|
this.lastRegistry = registry;
|
||||||
|
this.onRegistryUpdate(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.onError('Failed to poll registry:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load registry from file with cache-busting
|
||||||
|
*/
|
||||||
|
async loadRegistry() {
|
||||||
|
// Add cache-busting timestamp
|
||||||
|
this._registryTs = Date.now();
|
||||||
|
const url = `${this.registryPath}?_registry_ts=${this._registryTs}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load registry: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Validate registry structure
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Registry must be an array of portal objects');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to load registry: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if registry has changed
|
||||||
|
*/
|
||||||
|
hasChanged(newRegistry) {
|
||||||
|
if (!this.lastRegistry) {
|
||||||
|
return true; // First load
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick check: different length
|
||||||
|
if (this.lastRegistry.length !== newRegistry.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep check: compare JSON strings
|
||||||
|
const oldJson = JSON.stringify(this.lastRegistry);
|
||||||
|
const newJson = JSON.stringify(newRegistry);
|
||||||
|
|
||||||
|
return oldJson !== newJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply registry changes to the scene
|
||||||
|
*/
|
||||||
|
applyRegistry(registry) {
|
||||||
|
console.log(`[PortalRegistry] Applying ${registry.length} portals`);
|
||||||
|
|
||||||
|
// Store registry globally for access by other modules
|
||||||
|
window.portalRegistry = registry;
|
||||||
|
|
||||||
|
// Trigger custom event for other modules to listen to
|
||||||
|
const event = new CustomEvent('portalRegistryUpdated', {
|
||||||
|
detail: { registry, timestamp: Date.now() }
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Update portal scene objects if Three.js scene exists
|
||||||
|
if (window.scene && window.portalObjects) {
|
||||||
|
this.updatePortalSceneObjects(registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update portal scene objects in Three.js
|
||||||
|
*/
|
||||||
|
updatePortalSceneObjects(registry) {
|
||||||
|
// Remove existing portal objects
|
||||||
|
for (const portalId in window.portalObjects) {
|
||||||
|
const portalObj = window.portalObjects[portalId];
|
||||||
|
if (portalObj && portalObj.parent) {
|
||||||
|
portalObj.parent.remove(portalObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.portalObjects = {};
|
||||||
|
|
||||||
|
// Create new portal objects
|
||||||
|
for (const portal of registry) {
|
||||||
|
try {
|
||||||
|
const portalObj = this.createPortalObject(portal);
|
||||||
|
if (portalObj) {
|
||||||
|
window.portalObjects[portal.id] = portalObj;
|
||||||
|
window.scene.add(portalObj);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PortalRegistry] Failed to create portal ${portal.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PortalRegistry] Updated ${Object.keys(window.portalObjects).length} portal objects`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a portal object from registry data
|
||||||
|
*/
|
||||||
|
createPortalObject(portal) {
|
||||||
|
// This is a placeholder - actual implementation depends on Three.js setup
|
||||||
|
// In a real implementation, this would create the portal mesh/object
|
||||||
|
|
||||||
|
const portalObj = {
|
||||||
|
id: portal.id,
|
||||||
|
name: portal.name,
|
||||||
|
position: portal.position,
|
||||||
|
status: portal.status,
|
||||||
|
color: portal.color,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
update: function(newData) {
|
||||||
|
Object.assign(this, newData);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy: function() {
|
||||||
|
// Cleanup
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return portalObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current registry
|
||||||
|
*/
|
||||||
|
getRegistry() {
|
||||||
|
return this.lastRegistry || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get portal by ID
|
||||||
|
*/
|
||||||
|
getPortalById(id) {
|
||||||
|
const registry = this.getRegistry();
|
||||||
|
return registry.find(p => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get portals by status
|
||||||
|
*/
|
||||||
|
getPortalsByStatus(status) {
|
||||||
|
const registry = this.getRegistry();
|
||||||
|
return registry.filter(p => p.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get watcher status
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
isWatching: this.isWatching,
|
||||||
|
pollInterval: this.pollInterval,
|
||||||
|
registryPath: this.registryPath,
|
||||||
|
portalCount: this.lastRegistry ? this.lastRegistry.length : 0,
|
||||||
|
lastUpdate: this.lastRegistry ? new Date().toISOString() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = PortalRegistryWatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance for browser use
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.PortalRegistryWatcher = PortalRegistryWatcher;
|
||||||
|
|
||||||
|
// Auto-initialize if portal container exists
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const container = document.getElementById('portal-container') || document.getElementById('nexus-canvas');
|
||||||
|
if (container) {
|
||||||
|
const watcher = new PortalRegistryWatcher({
|
||||||
|
pollInterval: 5000,
|
||||||
|
onRegistryUpdate: (registry) => {
|
||||||
|
console.log(`[PortalRegistry] Updated with ${registry.length} portals`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.startWatching();
|
||||||
|
|
||||||
|
// Store globally for access
|
||||||
|
window.portalRegistryWatcher = watcher;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
214
tests/test_portal_registry_watcher.js
Normal file
214
tests/test_portal_registry_watcher.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Portal Registry Watcher
|
||||||
|
* Issue #1623: feat: portal hot-reload from portals.json without server restart
|
||||||
|
*/
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
// Simulate loading portals.json
|
||||||
|
if (url.includes('portals.json')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => [
|
||||||
|
{
|
||||||
|
id: 'test-portal-1',
|
||||||
|
name: 'Test Portal 1',
|
||||||
|
status: 'online',
|
||||||
|
color: '#ff0000',
|
||||||
|
position: { x: 10, y: 0, z: 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'test-portal-2',
|
||||||
|
name: 'Test Portal 2',
|
||||||
|
status: 'offline',
|
||||||
|
color: '#00ff00',
|
||||||
|
position: { x: -10, y: 0, z: 0 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected URL: ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock CustomEvent
|
||||||
|
global.CustomEvent = class CustomEvent {
|
||||||
|
constructor(type, options) {
|
||||||
|
this.type = type;
|
||||||
|
this.detail = options.detail;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock window
|
||||||
|
global.window = {
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: () => {},
|
||||||
|
portalObjects: {},
|
||||||
|
scene: {
|
||||||
|
add: () => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock document
|
||||||
|
global.document = {
|
||||||
|
addEventListener: () => {},
|
||||||
|
getElementById: () => null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load portal-registry-watcher.js
|
||||||
|
const watcherPath = path.join(ROOT, 'js', 'portal-registry-watcher.js');
|
||||||
|
const watcherCode = fs.readFileSync(watcherPath, 'utf8');
|
||||||
|
|
||||||
|
// Execute in context
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const context = {
|
||||||
|
module: { exports: {} },
|
||||||
|
exports: {},
|
||||||
|
console,
|
||||||
|
window: global.window,
|
||||||
|
document: global.document,
|
||||||
|
fetch: global.fetch,
|
||||||
|
CustomEvent: global.CustomEvent,
|
||||||
|
setInterval: () => {},
|
||||||
|
clearInterval: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.runInNewContext(watcherCode, context);
|
||||||
|
|
||||||
|
// Get PortalRegistryWatcher
|
||||||
|
const PortalRegistryWatcher = context.window.PortalRegistryWatcher || context.module.exports;
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher loads correctly', () => {
|
||||||
|
assert.ok(PortalRegistryWatcher, 'PortalRegistryWatcher should be defined');
|
||||||
|
assert.ok(typeof PortalRegistryWatcher === 'function', 'PortalRegistryWatcher should be a constructor');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher can be instantiated', () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
assert.ok(watcher, 'PortalRegistryWatcher instance should be created');
|
||||||
|
assert.equal(watcher.pollInterval, 5000, 'Should have default poll interval');
|
||||||
|
assert.equal(watcher.registryPath, './portals.json', 'Should have default registry path');
|
||||||
|
assert.ok(!watcher.isWatching, 'Should not be watching initially');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher can load registry', async () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
|
||||||
|
const registry = await watcher.loadRegistry();
|
||||||
|
|
||||||
|
assert.ok(registry, 'Should return registry');
|
||||||
|
assert.ok(Array.isArray(registry), 'Registry should be an array');
|
||||||
|
assert.equal(registry.length, 2, 'Should have 2 portals');
|
||||||
|
assert.equal(registry[0].id, 'test-portal-1', 'First portal should have correct ID');
|
||||||
|
assert.equal(registry[1].id, 'test-portal-2', 'Second portal should have correct ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher detects changes', async () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
|
||||||
|
// Load initial registry
|
||||||
|
const registry1 = await watcher.loadRegistry();
|
||||||
|
watcher.lastRegistry = registry1;
|
||||||
|
|
||||||
|
// Load same registry again
|
||||||
|
const registry2 = await watcher.loadRegistry();
|
||||||
|
const changed1 = watcher.hasChanged(registry2);
|
||||||
|
|
||||||
|
assert.ok(!changed1, 'Should not detect change with same registry');
|
||||||
|
|
||||||
|
// Create a different registry manually
|
||||||
|
const differentRegistry = [
|
||||||
|
{ id: 'different-portal', name: 'Different Portal' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Test hasChanged with different registry
|
||||||
|
const changed2 = watcher.hasChanged(differentRegistry);
|
||||||
|
|
||||||
|
assert.ok(changed2, 'Should detect change with different registry');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher can start and stop watching', () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
|
||||||
|
// Start watching
|
||||||
|
watcher.startWatching();
|
||||||
|
assert.ok(watcher.isWatching, 'Should be watching after start');
|
||||||
|
|
||||||
|
// Stop watching
|
||||||
|
watcher.stopWatching();
|
||||||
|
assert.ok(!watcher.isWatching, 'Should not be watching after stop');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher applies registry', () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
|
||||||
|
const registry = [
|
||||||
|
{ id: 'portal-1', name: 'Portal 1' },
|
||||||
|
{ id: 'portal-2', name: 'Portal 2' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock window.dispatchEvent
|
||||||
|
let eventDispatched = false;
|
||||||
|
global.window.dispatchEvent = (event) => {
|
||||||
|
if (event.type === 'portalRegistryUpdated') {
|
||||||
|
eventDispatched = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watcher.applyRegistry(registry);
|
||||||
|
|
||||||
|
assert.ok(eventDispatched, 'Should dispatch portalRegistryUpdated event');
|
||||||
|
assert.deepEqual(window.portalRegistry, registry, 'Should store registry globally');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher can get portal by ID', async () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
|
||||||
|
// Load registry
|
||||||
|
await watcher.loadRegistry();
|
||||||
|
watcher.lastRegistry = await watcher.loadRegistry();
|
||||||
|
|
||||||
|
const portal = watcher.getPortalById('test-portal-1');
|
||||||
|
assert.ok(portal, 'Should find portal by ID');
|
||||||
|
assert.equal(portal.name, 'Test Portal 1', 'Should have correct name');
|
||||||
|
|
||||||
|
const missing = watcher.getPortalById('non-existent');
|
||||||
|
assert.ok(!missing, 'Should return undefined for non-existent portal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher can get portals by status', async () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
|
||||||
|
// Load registry
|
||||||
|
await watcher.loadRegistry();
|
||||||
|
watcher.lastRegistry = await watcher.loadRegistry();
|
||||||
|
|
||||||
|
const online = watcher.getPortalsByStatus('online');
|
||||||
|
assert.equal(online.length, 1, 'Should have 1 online portal');
|
||||||
|
assert.equal(online[0].id, 'test-portal-1', 'Should be test-portal-1');
|
||||||
|
|
||||||
|
const offline = watcher.getPortalsByStatus('offline');
|
||||||
|
assert.equal(offline.length, 1, 'Should have 1 offline portal');
|
||||||
|
assert.equal(offline[0].id, 'test-portal-2', 'Should be test-portal-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PortalRegistryWatcher can get status', () => {
|
||||||
|
const watcher = new PortalRegistryWatcher();
|
||||||
|
|
||||||
|
const status = watcher.getStatus();
|
||||||
|
|
||||||
|
assert.ok(status, 'Should return status object');
|
||||||
|
assert.equal(status.isWatching, false, 'Should not be watching');
|
||||||
|
assert.equal(status.pollInterval, 5000, 'Should have correct poll interval');
|
||||||
|
assert.equal(status.registryPath, './portals.json', 'Should have correct path');
|
||||||
|
assert.equal(status.portalCount, 0, 'Should have 0 portals initially');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('All Portal Registry Watcher tests passed!');
|
||||||
Reference in New Issue
Block a user