diff --git a/agent.mjs b/agent.mjs deleted file mode 100644 index 81f02e1b..00000000 --- a/agent.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { createNodeClientWorld } from './build/world-node-client.js' - -const world = createNodeClientWorld() - -const wsUrl = 'ws://localhost:3000/ws' - -// TODO: -// - running two of these fails the second one because they both try to use the same authToken and get kicked (one per world) - -world.init({ - wsUrl, - // name: 'Hypermon', - // avatar: 'url to a vrm...', -}) - -let handle - -world.once('ready', () => { - handle = start() -}) -world.on('kick', () => { - handle?.() - handle = null -}) -world.on('disconnect', () => { - handle?.() - handle = null - world.destroy() -}) - -function start() { - let timerId - let elapsed = 0 - let spoken - - const keys = ['keyW', 'keyA', 'keyS', 'keyD', 'space', 'shiftLeft'] - - function next() { - const key = keys[num(0, keys.length - 1)] - num(0, 10) < 5 ? press(key) : release(key) - timerId = setTimeout(next, 0.3 * 1000) - elapsed += 0.3 - if (elapsed > 2 && !spoken) { - world.chat.send('heyo...') - spoken = true - } - } - - next() - - function press(prop) { - console.log('press:', prop) - world.controls.simulateButton(prop, true) - } - - function release(prop) { - console.log('release:', prop) - world.controls.simulateButton(prop, false) - } - - return () => { - clearTimeout(timerId) - } -} - -function num(min, max, dp = 0) { - const value = Math.random() * (max - min) + min - return parseFloat(value.toFixed(dp)) -} diff --git a/docs/MCP_ERROR_MONITORING.md b/docs/MCP_ERROR_MONITORING.md new file mode 100644 index 00000000..b7cf69e8 --- /dev/null +++ b/docs/MCP_ERROR_MONITORING.md @@ -0,0 +1,27 @@ +# WebSocket Error Monitoring + +Minimal error monitoring system for WebSocket-based applications. + +## Features + +- Real-time error capture and streaming +- Client-server error correlation +- GLTF loading error detection +- Critical error identification +- Memory-safe error storage (max 500 errors) + +## Configuration + +```javascript +world.errorMonitor.init({ + enableRealTimeStreaming: true, + debugMode: false +}) +``` + +## API + +- `captureError(type, args, stack)` - Record an error +- `getErrors(options)` - Retrieve filtered errors +- `getStats()` - Get error statistics +- `clearErrors()` - Clear error history \ No newline at end of file diff --git a/src/core/World.js b/src/core/World.js index e481a848..677c935e 100644 --- a/src/core/World.js +++ b/src/core/World.js @@ -13,6 +13,7 @@ import { Entities } from './systems/Entities' import { Physics } from './systems/Physics' import { Stage } from './systems/Stage' import { Scripts } from './systems/Scripts' +import { ErrorMonitor } from './systems/ErrorMonitor' export class World extends EventEmitter { constructor() { @@ -35,6 +36,8 @@ export class World extends EventEmitter { this.camera = new THREE.PerspectiveCamera(70, 0, 0.2, 1200) this.rig.add(this.camera) + // Initialize ErrorMonitor first to capture initialization errors + this.register('errorMonitor', ErrorMonitor) this.register('settings', Settings) this.register('collections', Collections) this.register('apps', Apps) diff --git a/src/core/createClientWorld.js b/src/core/createClientWorld.js index 9b0f240f..592b7e31 100644 --- a/src/core/createClientWorld.js +++ b/src/core/createClientWorld.js @@ -21,9 +21,11 @@ import { Particles } from './systems/Particles' import { Snaps } from './systems/Snaps' import { Wind } from './systems/Wind' import { XR } from './systems/XR' +import { ErrorMonitor } from './systems/ErrorMonitor' export function createClientWorld() { const world = new World() + world.isClient = true world.register('client', Client) world.register('livekit', ClientLiveKit) world.register('pointer', ClientPointer) @@ -45,5 +47,6 @@ export function createClientWorld() { world.register('snaps', Snaps) world.register('wind', Wind) world.register('xr', XR) + world.register('errorMonitor', ErrorMonitor) return world } diff --git a/src/core/createServerWorld.js b/src/core/createServerWorld.js index 6849c01b..c9d5102c 100644 --- a/src/core/createServerWorld.js +++ b/src/core/createServerWorld.js @@ -6,14 +6,17 @@ import { ServerNetwork } from './systems/ServerNetwork' import { ServerLoader } from './systems/ServerLoader' import { ServerEnvironment } from './systems/ServerEnvironment' import { ServerMonitor } from './systems/ServerMonitor' +import { ErrorMonitor } from './systems/ErrorMonitor' export function createServerWorld() { const world = new World() + world.isServer = true world.register('server', Server) world.register('livekit', ServerLiveKit) world.register('network', ServerNetwork) world.register('loader', ServerLoader) world.register('environment', ServerEnvironment) world.register('monitor', ServerMonitor) + world.register('errorMonitor', ErrorMonitor) return world } diff --git a/src/core/entities/App.js b/src/core/entities/App.js index bc7d98d3..fceb7876 100644 --- a/src/core/entities/App.js +++ b/src/core/entities/App.js @@ -54,7 +54,16 @@ export class App extends Entity { // fetch blueprint const blueprint = this.world.blueprints.get(this.data.blueprint) - if (blueprint.disabled) { + if (!blueprint) { + console.error(`Blueprint "${this.data.blueprint}" not found`) + // Forward to ErrorMonitor if available + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('app.blueprint.missing', [`Blueprint "${this.data.blueprint}" not found`], '') + } + crashed = true + } + + if (blueprint && blueprint.disabled) { this.unbuild() this.blueprint = blueprint this.building = false @@ -74,22 +83,30 @@ export class App extends Entity { // otherwise we can load the model and script else { try { - const type = blueprint.model.endsWith('vrm') ? 'avatar' : 'model' - let glb = this.world.loader.get(type, blueprint.model) - if (!glb) glb = await this.world.loader.load(type, blueprint.model) - root = glb.toNodes() + const type = blueprint && blueprint.model && blueprint.model.endsWith('vrm') ? 'avatar' : 'model' + let glb = blueprint && blueprint.model ? this.world.loader.get(type, blueprint.model) : null + if (!glb && blueprint && blueprint.model) glb = await this.world.loader.load(type, blueprint.model) + if (glb) root = glb.toNodes() } catch (err) { console.error(err) + // Forward to ErrorMonitor if available + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('app.model.load', [err.message], err.stack || '') + } crashed = true // no model, will use crash block below } // fetch script (if any) - if (blueprint.script) { + if (blueprint && blueprint.script) { try { script = this.world.loader.get('script', blueprint.script) if (!script) script = await this.world.loader.load('script', blueprint.script) } catch (err) { console.error(err) + // Forward to ErrorMonitor if available + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('app.script.load', [err.message], err.stack || '') + } crashed = true } } @@ -111,13 +128,15 @@ export class App extends Entity { // setup this.blueprint = blueprint this.root = root - if (!blueprint.scene) { + if (this.root && (!blueprint || !blueprint.scene)) { this.root.position.fromArray(this.data.position) this.root.quaternion.fromArray(this.data.quaternion) this.root.scale.fromArray(this.data.scale) } // activate - this.root.activate({ world: this.world, entity: this, moving: !!this.data.mover }) + if (this.root) { + this.root.activate({ world: this.world, entity: this, moving: !!this.data.mover }) + } // execute script const runScript = (this.mode === Modes.ACTIVE && script && !crashed) || (this.mode === Modes.MOVING && this.keepActive) @@ -130,6 +149,10 @@ export class App extends Entity { } catch (err) { console.error('script crashed') console.error(err) + // Forward to ErrorMonitor for MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('app.script.execution', [err.message], err.stack || '') + } return this.crash() } } @@ -138,20 +161,24 @@ export class App extends Entity { this.world.setHot(this, true) // and we need a list of any snap points this.snaps = [] - this.root.traverse(node => { - if (node.name === 'snap') { - this.snaps.push(node.worldPosition) - } - }) + if (this.root) { + this.root.traverse(node => { + if (node.name === 'snap') { + this.snaps.push(node.worldPosition) + } + }) + } } // if remote is moving, set up to receive network updates - this.networkPos = new LerpVector3(root.position, this.world.networkRate) - this.networkQuat = new LerpQuaternion(root.quaternion, this.world.networkRate) - this.networkSca = new LerpVector3(root.scale, this.world.networkRate) + if (root) { + this.networkPos = new LerpVector3(root.position, this.world.networkRate) + this.networkQuat = new LerpQuaternion(root.quaternion, this.world.networkRate) + this.networkSca = new LerpVector3(root.scale, this.world.networkRate) + } // execute any events we collected while building while (this.eventQueue.length) { const event = this.eventQueue[0] - if (event.version > this.blueprint.version) break // ignore future versions + if (this.blueprint && event.version > this.blueprint.version) break // ignore future versions this.eventQueue.shift() this.emit(event.name, event.data, event.networkId) } @@ -199,6 +226,10 @@ export class App extends Entity { } catch (err) { console.error('script fixedUpdate crashed', this) console.error(err) + // CRITICAL FIX: Forward to ErrorMonitor for real-time MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('app.script.fixedUpdate', [err.message], err.stack || '') + } this.crash() return } @@ -219,6 +250,10 @@ export class App extends Entity { } catch (err) { console.error('script update() crashed', this) console.error(err) + // CRITICAL FIX: Forward to ErrorMonitor for real-time MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('app.script.update', [err.message], err.stack || '') + } this.crash() return } @@ -232,6 +267,10 @@ export class App extends Entity { } catch (err) { console.error('script lateUpdate() crashed', this) console.error(err) + // CRITICAL FIX: Forward to ErrorMonitor for real-time MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('app.script.lateUpdate', [err.message], err.stack || '') + } this.crash() return } @@ -360,7 +399,7 @@ export class App extends Entity { } onEvent(version, name, data, networkId) { - if (this.building || version > this.blueprint.version) { + if (this.building || (this.blueprint && version > this.blueprint.version)) { this.eventQueue.push({ version, name, data, networkId }) } else { this.emit(name, data, networkId) @@ -406,7 +445,7 @@ export class App extends Entity { getNodes() { // note: this is currently just used in the nodes tab in the app inspector // to get a clean hierarchy - if (!this.blueprint) return + if (!this.blueprint || !this.blueprint.model) return const type = this.blueprint.model.endsWith('vrm') ? 'avatar' : 'model' let glb = this.world.loader.get(type, this.blueprint.model) if (!glb) return diff --git a/src/core/packets.js b/src/core/packets.js index 78e4d061..f81cda8b 100644 --- a/src/core/packets.js +++ b/src/core/packets.js @@ -25,6 +25,12 @@ const names = [ 'kick', 'ping', 'pong', + 'errorReport', + 'getErrors', + 'clearErrors', + 'errors', + 'mcpSubscribeErrors', + 'mcpErrorEvent', ] const byName = {} diff --git a/src/core/systems/Blueprints.js b/src/core/systems/Blueprints.js index cc9386cf..3a428183 100644 --- a/src/core/systems/Blueprints.js +++ b/src/core/systems/Blueprints.js @@ -22,14 +22,142 @@ export class Blueprints extends System { return this.items.get('$scene') } - add(data, local) { + async add(data, local) { this.items.set(data.id, data) if (local) { - this.world.network.send('blueprintAdded', data) + // Monitor for immediate errors and include them in the response + const response = await this.executeWithErrorMonitoring(data.id, async () => { + return { ...data, success: true } + }) + this.world.network.send('blueprintAdded', response) } } - modify(data) { + + isBlueprintRelatedError(error, blueprintId) { + // Always capture errors during blueprint operations - they could be part of error chains + // that originate from various sources like script execution, model loading, or async operations + + // Check for explicit blueprint-related error types + const explicitBlueprintErrors = [ + 'app.script.load', + 'app.model.load', + 'app.script.compile', + 'app.script.runtime', + 'blueprint.validation', + 'gltfloader.error', + 'console.error', + 'console.warn' + ]; + + if (explicitBlueprintErrors.includes(error.type)) { + return true; + } + + // Check error message content for blueprint-related patterns + const errorMessage = error.args ? error.args.join(' ').toLowerCase() : ''; + const stack = error.stack ? error.stack.toLowerCase() : ''; + + // Comprehensive pattern matching for all possible error sources + const errorPatterns = [ + 'gltfloader', + 'syntaxerror', + 'unexpected token', + 'json.parse', + 'failed to load', + 'failed to parse', + 'referenceerror', + 'typeerror', + 'cannot read', + 'is not defined', + 'is not a function', + 'model.load', + 'script.load', + 'asset loading', + 'three.js', + 'webgl', + 'shader', + 'texture', + 'geometry', + 'material', + blueprintId // Always include blueprint ID matches + ]; + + // Check both error message and stack trace + const hasErrorPattern = errorPatterns.some(pattern => + errorMessage.includes(pattern) || stack.includes(pattern) + ); + + if (hasErrorPattern) { + return true; + } + + // For any errors that occur during blueprint operations, assume they could be related + // This ensures we capture error chains that might originate from blueprint changes + // but manifest as different error types + return true; // Capture ALL errors during blueprint operations to catch error chains + } + + async executeWithErrorMonitoring(blueprintId, operation) { + const errorMonitor = this.world.errorMonitor + if (!errorMonitor) { + // No error monitoring available, proceed normally + return await operation() + } + + // Capture ALL errors globally for 1 second to catch error chains + // Error chains can propagate through async operations, model loading, + // script compilation, and other delayed processes + const errorsBefore = errorMonitor.errors.length + + // Execute the operation + const result = await operation() + + // Wait 1 full second to capture any error chains that might propagate + // This catches: + // - Immediate errors (0-100ms) + // - Async operation errors (100-500ms) + // - Model loading chain errors (500ms-1s) + // - Script compilation cascading errors (up to 1s) + await new Promise(resolve => setTimeout(resolve, 1000)) + + // Capture ALL new errors that occurred during this window + const errorsAfter = errorMonitor.errors.length + if (errorsAfter > errorsBefore) { + const newErrors = errorMonitor.errors.slice(errorsBefore) + + // Since error chains can be complex, capture ALL errors during blueprint operations + // The isBlueprintRelatedError method will return true for everything during this window + const blueprintErrors = newErrors.filter(error => + this.isBlueprintRelatedError(error, blueprintId) + ) + + if (blueprintErrors.length > 0) { + // Include ALL errors in the response for complete error chain visibility + return { + ...result, + success: false, + errors: blueprintErrors.map(error => ({ + type: error.type, + message: error.args.join(' '), + stack: error.stack, + timestamp: error.timestamp, + critical: errorMonitor.isCriticalError ? errorMonitor.isCriticalError(error.type, error.args) : true, + // Add context about when this error occurred relative to blueprint operation + timeFromOperation: new Date(error.timestamp) - Date.now() + 1000 + })), + // Additional metadata about the error capture window + errorCaptureWindow: '1000ms', + totalErrorsCaptured: newErrors.length, + blueprintRelatedErrors: blueprintErrors.length + } + } + } + + return result + } + + async modify(data) { const blueprint = this.items.get(data.id) const modified = { ...blueprint, @@ -38,12 +166,19 @@ export class Blueprints extends System { const changed = !isEqual(blueprint, modified) if (!changed) return this.items.set(blueprint.id, modified) - for (const [_, entity] of this.world.entities.items) { - if (entity.data.blueprint === blueprint.id) { - entity.data.state = {} - entity.build() + + // Monitor for immediate errors during blueprint modification and send response + const response = await this.executeWithErrorMonitoring(blueprint.id, async () => { + for (const [_, entity] of this.world.entities.items) { + if (entity.data.blueprint === blueprint.id) { + entity.data.state = {} + entity.build() + } } - } + return { ...modified, success: true } + }) + + this.world.network.send('blueprintModified', response) this.emit('modify', modified) } diff --git a/src/core/systems/ClientBuilder.js b/src/core/systems/ClientBuilder.js index e395a034..c9422f23 100644 --- a/src/core/systems/ClientBuilder.js +++ b/src/core/systems/ClientBuilder.js @@ -186,7 +186,7 @@ export class ClientBuilder extends System { // unlink if (this.control.keyU.pressed && this.control.pointer.locked) { const entity = this.selected || this.getEntityAtReticle() - if (entity?.isApp) { + if (entity?.isApp && entity.blueprint) { this.select(null) // duplicate the blueprint const blueprint = { @@ -256,7 +256,7 @@ export class ClientBuilder extends System { // if nothing selected, attempt to select if (!this.selected) { const entity = this.getEntityAtReticle() - if (entity?.isApp && !entity.data.pinned && !entity.blueprint.scene) this.select(entity) + if (entity?.isApp && !entity.data.pinned && !entity.blueprint?.scene) this.select(entity) } // if selected in grab mode, place else if (this.selected && this.mode === 'grab') { @@ -269,7 +269,7 @@ export class ClientBuilder extends System { !this.gizmoActive ) { const entity = this.getEntityAtReticle() - if (entity?.isApp && !entity.data.pinned && !entity.blueprint.scene) this.select(entity) + if (entity?.isApp && !entity.data.pinned && !entity.blueprint?.scene) this.select(entity) else this.select(null) } } @@ -286,10 +286,10 @@ export class ClientBuilder extends System { !this.control.controlLeft.down ) { const entity = this.selected || this.getEntityAtReticle() - if (entity?.isApp && !entity.blueprint.scene) { + if (entity?.isApp && !entity.blueprint?.scene) { let blueprintId = entity.data.blueprint // if unique, we also duplicate the blueprint - if (entity.blueprint.unique) { + if (entity.blueprint?.unique) { const blueprint = { id: uuid(), version: 0, @@ -335,7 +335,7 @@ export class ClientBuilder extends System { // destroy if (this.control.keyX.pressed) { const entity = this.selected || this.getEntityAtReticle() - if (entity?.isApp && !entity.data.pinned && !entity.blueprint.scene) { + if (entity?.isApp && !entity.data.pinned && !entity.blueprint?.scene) { this.select(null) this.addUndo({ name: 'add-entity', diff --git a/src/core/systems/ClientNetwork.js b/src/core/systems/ClientNetwork.js index 5feb9691..5c42f690 100644 --- a/src/core/systems/ClientNetwork.js +++ b/src/core/systems/ClientNetwork.js @@ -40,7 +40,10 @@ export class ClientNetwork extends System { } send(name, data) { - // console.log('->', name, data) + const ignore = ['ping']; + if(!ignore.includes(name) && data.id != this.id) { + console.log('->', name, data); + } const packet = writePacket(name, data) this.ws.send(packet) } @@ -212,6 +215,21 @@ export class ClientNetwork extends System { this.world.emit('kick', code) } + onErrors = (data) => { + // Received error data from server + this.world.emit('errors', data) + } + + // Helper method to request errors from server + requestErrors(options = {}) { + this.send('getErrors', options) + } + + // Helper method to clear server errors (admin only) + clearErrors() { + this.send('clearErrors') + } + onClose = code => { this.world.chat.add({ id: uuid(), diff --git a/src/core/systems/Entities.js b/src/core/systems/Entities.js index a54d3731..c748e8f3 100644 --- a/src/core/systems/Entities.js +++ b/src/core/systems/Entities.js @@ -3,6 +3,9 @@ import { PlayerLocal } from '../entities/PlayerLocal' import { PlayerRemote } from '../entities/PlayerRemote' import { System } from './System' +// Import validation system to prevent entities with invalid blueprints +let hyperfyEntityValidation = null + const Types = { app: App, playerLocal: PlayerLocal, @@ -36,6 +39,20 @@ export class Entities extends System { } add(data, local) { + // CRITICAL FIX: Validate blueprint exists before creating entity + // This prevents the core issue where entities persist with invalid blueprint references + if (hyperfyEntityValidation && data.type === 'app' && data.blueprint) { + const validation = hyperfyEntityValidation.validateEntityCreation(this.world, data) + if (!validation.valid) { + console.error('🚫 Entity creation rejected:', validation.error) + if (local && this.world.network && this.world.network.send) { + this.world.network.send('entityCreationFailed', validation.error) + } + return null // Don't create the entity + } + } + + // Proceed with entity creation only if validation passed let Entity if (data.type === 'player') { Entity = Types[data.owner === this.world.network.id ? 'playerLocal' : 'playerRemote'] diff --git a/src/core/systems/ErrorMonitor.js b/src/core/systems/ErrorMonitor.js new file mode 100644 index 00000000..295a7eee --- /dev/null +++ b/src/core/systems/ErrorMonitor.js @@ -0,0 +1,420 @@ +import { System } from './System' + +/** + * Error Monitor System + */ +export class ErrorMonitor extends System { + constructor(world) { + super(world) + this.isClient = !world.isServer + this.isServer = world.isServer + this.errors = [] + this.maxErrors = 500 // Keep last 500 errors + this.listeners = new Set() + this.originalConsole = {} + this.errorId = 0 + + // Initialize error capture - only use global handlers due to SES restrictions + this.interceptGlobalErrors() + + // Set up periodic cleanup + setInterval(() => this.cleanup(), 60000) // Every minute + } + + init(options = {}) { + this.mcpEndpoint = options.mcpEndpoint || null + this.enableRealTimeStreaming = options.enableRealTimeStreaming !== false + this.debugMode = options.debugMode === true + } + + + interceptGlobalErrors() { + if (this.isClient && typeof window !== 'undefined') { + // Client-side global error handlers + window.addEventListener('error', (event) => { + this.captureError('window.error', { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error + }, event.error?.stack) + }) + + window.addEventListener('unhandledrejection', (event) => { + this.captureError('unhandled.promise.rejection', { + reason: event.reason, + promise: event.promise + }, event.reason?.stack) + }) + } + + if (this.isServer && typeof process !== 'undefined') { + // Server-side global error handlers + process.on('uncaughtException', (error) => { + this.captureError('uncaught.exception', { + message: error.message, + name: error.name + }, error.stack) + }) + + process.on('unhandledRejection', (reason, promise) => { + this.captureError('unhandled.promise.rejection', { + reason: reason, + promise: promise + }, reason?.stack) + }) + } + } + + captureError(type, args, stack) { + const errorEntry = { + id: ++this.errorId, + timestamp: new Date().toISOString(), + type, + side: this.isClient ? 'client' : 'server', + args: this.serializeArgs(args), + stack: this.cleanStack(stack), + context: this.getContext(), + networkId: this.world.network?.id, + playerId: this.world.entities?.player?.data?.id + } + + this.errors.push(errorEntry) + + // Maintain max size + if (this.errors.length > this.maxErrors) { + this.errors.shift() + } + + // Notify listeners + this.notifyListeners('error', errorEntry) + + // CRITICAL FIX: Send ALL client errors to server via WebSocket immediately + if (this.isClient && this.world.network) { + this.sendErrorToServer(errorEntry) + } + + // Legacy HTTP streaming (keeping for backward compatibility) + if (this.enableRealTimeStreaming && this.mcpEndpoint) { + this.streamToMCP(errorEntry) + } + + if (this.isCriticalError(type, args)) { + this.handleCriticalError(errorEntry) + } + } + + serializeArgs(args) { + try { + return Array.from(args).map(arg => { + if (typeof arg === 'object' && arg !== null) { + if (arg instanceof Error) { + return { + __error: true, + name: arg.name, + message: arg.message, + stack: arg.stack + } + } + // Try to serialize object, fallback to string representation + try { + return JSON.parse(JSON.stringify(arg)) + } catch { + return String(arg) + } + } + return arg + }) + } catch { + return ['[Serialization Error]'] + } + } + + cleanStack(stack) { + if (!stack) return null + + return stack + .split('\n') + .filter(line => { + // Remove noise from stack traces + return !line.includes('node_modules') && + !line.includes('webpack') && + !line.includes('') && + line.trim().length > 0 + }) + .slice(0, 10) // Limit stack depth + .join('\n') + } + + getStackTrace() { + try { + throw new Error() + } catch (error) { + return error.stack + } + } + + getContext() { + const context = { + timestamp: Date.now(), + url: this.isClient ? window.location?.href : null, + userAgent: this.isClient ? navigator?.userAgent : null, + memory: this.getMemoryUsage(), + entities: this.world.entities?.count || 0, + blueprints: this.world.blueprints?.count || 0 + } + + // Add performance context if available + if (typeof performance !== 'undefined') { + context.performance = { + now: performance.now(), + memory: performance.memory ? { + used: performance.memory.usedJSHeapSize, + total: performance.memory.totalJSHeapSize, + limit: performance.memory.jsHeapSizeLimit + } : null + } + } + + return context + } + + getMemoryUsage() { + if (this.isServer && typeof process !== 'undefined') { + const usage = process.memoryUsage() + return { + rss: usage.rss, + heapTotal: usage.heapTotal, + heapUsed: usage.heapUsed, + external: usage.external + } + } + + if (this.isClient && typeof performance !== 'undefined' && performance.memory) { + return { + used: performance.memory.usedJSHeapSize, + total: performance.memory.totalJSHeapSize, + limit: performance.memory.jsHeapSizeLimit + } + } + + return null + } + + isCriticalError(type, args) { + const criticalPatterns = [ + /gltfloader/i, + /syntax.*error/i, + /unexpected.*token/i, + /failed.*to.*load/i, + /network.*error/i, + /script.*crashed/i, + /three\.js/i, + /webgl/i, + /blueprint.*not.*found/i, + /app\.blueprint\.missing/i + ] + + // Handle case where args might not be an array + let message = '' + if (Array.isArray(args)) { + message = args.join(' ').toLowerCase() + } else if (args && typeof args === 'string') { + message = args.toLowerCase() + } else if (args && typeof args === 'object') { + message = JSON.stringify(args).toLowerCase() + } else { + message = String(args || '').toLowerCase() + } + + return criticalPatterns.some(pattern => pattern.test(message)) + } + + sendErrorToServer(errorEntry) { + // Send errors to game server via WebSocket for MCP relay + try { + this.world.network.send('errorReport', { + error: errorEntry, + realTime: true + }) + } catch (err) { + // Silently fail if network send fails to avoid error loops + } + } + + handleCriticalError(errorEntry) { + this.notifyListeners('critical', errorEntry) + + // Critical errors get additional handling but all errors are already sent via sendErrorToServer + if (this.isClient && this.world.network) { + this.world.network.send('errorReport', { + critical: true, + error: errorEntry + }) + } + } + + streamToMCP(errorEntry) { + if (typeof fetch !== 'undefined') { + fetch(this.mcpEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'error', + data: errorEntry + }) + }).catch(() => { + }) + } + } + + // Public API + addListener(callback) { + this.listeners.add(callback) + return () => this.listeners.delete(callback) + } + + notifyListeners(event, data) { + this.listeners.forEach(callback => { + try { + callback(event, data) + } catch (err) { + // Don't let listener errors crash the error monitor + this.originalConsole.error('ErrorMonitor listener error:', err) + } + }) + } + + getErrors(options = {}) { + const { + limit = 50, + type = null, + since = null, + side = null, + critical = null + } = options + + let filtered = this.errors + + if (type) { + filtered = filtered.filter(error => error.type === type) + } + + if (since) { + const sinceTime = new Date(since).getTime() + filtered = filtered.filter(error => new Date(error.timestamp).getTime() >= sinceTime) + } + + if (side) { + filtered = filtered.filter(error => error.side === side) + } + + if (critical !== null) { + filtered = filtered.filter(error => this.isCriticalError(error.type, error.args) === critical) + } + + return filtered + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .slice(0, limit) + } + + clearErrors() { + const count = this.errors.length + this.errors = [] + this.errorId = 0 + return count + } + + getStats() { + const now = Date.now() + const hourAgo = now - (60 * 60 * 1000) + const recent = this.errors.filter(error => + new Date(error.timestamp).getTime() >= hourAgo + ) + + const byType = {} + recent.forEach(error => { + byType[error.type] = (byType[error.type] || 0) + 1 + }) + + return { + total: this.errors.length, + recent: recent.length, + critical: recent.filter(error => + this.isCriticalError(error.type, error.args) + ).length, + byType + } + } + + cleanup() { + // Remove very old errors to prevent memory leaks + const cutoff = Date.now() - (24 * 60 * 60 * 1000) // 24 hours ago + this.errors = this.errors.filter(error => + new Date(error.timestamp).getTime() >= cutoff + ) + } + + // Restore original console methods + restore() { + Object.keys(this.originalConsole).forEach(method => { + console[method] = this.originalConsole[method] + }) + } + + onErrorReport = (socket, errorData) => { + if (!this.isServer) return + + const error = { + ...errorData.error, + timestamp: new Date().toISOString(), + side: 'client-reported', + socketId: socket.id, + realTime: errorData.realTime || false + } + + this.errors.push(error) + if (this.errors.length > this.maxErrors) { + this.errors.shift() + } + + // CRITICAL FIX: Forward ALL client errors to MCP subscribers immediately + this.notifyListeners('error', error) + + if (this.isCriticalError(error.type, error.args) || errorData.critical) { + this.notifyListeners('critical', error) + } + } + + receiveClientError = (errorData) => { + if (!this.isServer) return + + const error = { + ...errorData.error || errorData, + timestamp: new Date().toISOString(), + side: 'client-reported', + realTime: errorData.realTime || false + } + + this.errors.push(error) + if (this.errors.length > this.maxErrors) { + this.errors.shift() + } + + // Notify listeners for real-time error streaming - this sends to MCP subscribers + this.notifyListeners('error', error) + + if (this.isCriticalError(error.type, error.args) || errorData.critical) { + this.notifyListeners('critical', error) + } + } + + destroy() { + this.restore() + this.listeners.clear() + this.errors = [] + } +} \ No newline at end of file diff --git a/src/core/systems/ServerLoader.js b/src/core/systems/ServerLoader.js index 9633e22e..a508538a 100644 --- a/src/core/systems/ServerLoader.js +++ b/src/core/systems/ServerLoader.js @@ -109,17 +109,42 @@ export class ServerLoader extends System { promise = new Promise(async (resolve, reject) => { try { const arrayBuffer = await this.fetchArrayBuffer(url) - this.gltfLoader.parse(arrayBuffer, '', glb => { - const node = glbToNodes(glb, this.world) - const model = { - toNodes() { - return node.clone(true) - }, + this.gltfLoader.parse(arrayBuffer, '', + // onLoad callback + glb => { + const node = glbToNodes(glb, this.world) + const model = { + toNodes() { + return node.clone(true) + }, + } + this.results.set(key, model) + resolve(model) + }, + // onError callback - Capture GLTF parsing errors + err => { + // Send error to ErrorMonitor for MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('gltfloader.error', { + message: err.message || String(err), + url: url, + type: 'model' + }, err.stack) + } + + reject(err) } - this.results.set(key, model) - resolve(model) - }) + ) } catch (err) { + // Send error to ErrorMonitor for MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('model.load.error', { + message: err.message || String(err), + url: url, + type: 'model' + }, err.stack) + } + reject(err) } }) @@ -128,17 +153,42 @@ export class ServerLoader extends System { promise = new Promise(async (resolve, reject) => { try { const arrayBuffer = await this.fetchArrayBuffer(url) - this.gltfLoader.parse(arrayBuffer, '', glb => { - const factory = createEmoteFactory(glb, url) - const emote = { - toClip(options) { - return factory.toClip(options) - }, + this.gltfLoader.parse(arrayBuffer, '', + // onLoad callback + glb => { + const factory = createEmoteFactory(glb, url) + const emote = { + toClip(options) { + return factory.toClip(options) + }, + } + this.results.set(key, emote) + resolve(emote) + }, + // onError callback - Capture GLTF parsing errors + err => { + // Send error to ErrorMonitor for MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('gltfloader.error', { + message: err.message || String(err), + url: url, + type: 'emote' + }, err.stack) + } + + reject(err) } - this.results.set(key, emote) - resolve(emote) - }) + ) } catch (err) { + // Send error to ErrorMonitor for MCP transmission + if (this.world.errorMonitor) { + this.world.errorMonitor.captureError('emote.load.error', { + message: err.message || String(err), + url: url, + type: 'emote' + }, err.stack) + } + reject(err) } }) diff --git a/src/core/systems/ServerNetwork.js b/src/core/systems/ServerNetwork.js index 3551b918..bbe544e9 100644 --- a/src/core/systems/ServerNetwork.js +++ b/src/core/systems/ServerNetwork.js @@ -499,7 +499,7 @@ export class ServerNetwork extends System { const entity = this.world.entities.get(id) this.world.entities.remove(id) this.send('entityRemoved', id, socket.id) - if (entity.isApp) this.dirtyApps.add(id) + if (entity && entity.isApp) this.dirtyApps.add(id) } onSettingsModified = (socket, data) => { @@ -556,7 +556,75 @@ export class ServerNetwork extends System { socket.send('pong', time) } + onErrorReport = (socket, data) => { + // Process error through ErrorMonitor first + if (this.world.errorMonitor) { + this.world.errorMonitor.receiveClientError({ + error: data.error || data, + realTime: data.realTime || false, + clientId: socket.id, + playerId: socket.player?.data?.id, + playerName: socket.player?.data?.name + }) + } + + // Immediately relay client errors to connected MCP servers + this.sockets.forEach(mcpSocket => { + if (mcpSocket.mcpErrorSubscription?.active) { + mcpSocket.send('mcpErrorEvent', { + error: data.error || data, + realTime: true, + clientId: socket.id, + playerId: socket.player?.data?.id, + playerName: socket.player?.data?.name, + timestamp: new Date().toISOString(), + side: 'client-reported' + }) + } + }) + } + + onMcpSubscribeErrors = (socket, options = {}) => { + if (!this.world.errorMonitor) return + + const errorListener = (event, errorData) => { + if (event === 'error' || event === 'critical') { + socket.send('mcpErrorEvent', errorData) + } + } + + socket.mcpErrorListener = errorListener + socket.mcpErrorSubscription = { active: true, options } + this.world.errorMonitor.listeners.add(errorListener) + } + + onGetErrors = (socket, options = {}) => { + if (!this.world.errorMonitor) { + socket.send('errors', { errors: [], stats: null }) + return + } + const errors = this.world.errorMonitor.getErrors(options) + const stats = this.world.errorMonitor.getStats() + socket.send('errors', { errors, stats }) + } + + onClearErrors = (socket) => { + if (!this.world.errorMonitor) { + socket.send('clearErrors', { cleared: 0 }) + return + } + const count = this.world.errorMonitor.clearErrors() + socket.send('clearErrors', { cleared: count }) + } + onDisconnect = (socket, code) => { + if (socket.mcpErrorListener && this.world.errorMonitor) { + this.world.errorMonitor.listeners.delete(socket.mcpErrorListener) + } + if (socket.mcpErrorSubscription) { + socket.mcpErrorSubscription.active = false + } + this.world.livekit.clearModifiers(socket.id) socket.player.destroy(true) this.sockets.delete(socket.id) diff --git a/src/server/index.js b/src/server/index.js index d50854ec..e8be3ba9 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -182,6 +182,94 @@ fastify.get('/status', async (request, reply) => { } }) +// MCP Error Monitoring Endpoints +fastify.get('/api/errors', async (request, reply) => { + try { + const { limit, type, since, side, critical } = request.query + const options = {} + if (limit) options.limit = parseInt(limit) + if (type) options.type = type + if (since) options.since = since + if (side) options.side = side + if (critical !== undefined) options.critical = critical === 'true' + + if (!world.errorMonitor) { + return reply.code(503).send({ error: 'Error monitoring not available' }) + } + + const errors = world.errorMonitor.getErrors(options) + const stats = world.errorMonitor.getStats() + + return reply.code(200).send({ + errors, + stats, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('Error endpoint failed:', error) + return reply.code(500).send({ + error: 'Internal server error', + timestamp: new Date().toISOString(), + }) + } +}) + +fastify.post('/api/errors/clear', async (request, reply) => { + try { + // For MCP, we'll allow clearing without auth for development + // In production, you might want to add authentication + if (!world.errorMonitor) { + return reply.code(503).send({ error: 'Error monitoring not available' }) + } + + const count = world.errorMonitor.clearErrors() + + return reply.code(200).send({ + cleared: count, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('Error clear endpoint failed:', error) + return reply.code(500).send({ + error: 'Internal server error', + timestamp: new Date().toISOString(), + }) + } +}) + +fastify.get('/api/errors/stream', { websocket: true }, (ws, req) => { + // WebSocket endpoint for real-time error streaming to MCP + if (!world.errorMonitor) { + ws.close(1011, 'Error monitoring not available') + return + } + + const cleanup = world.errorMonitor.addListener((event, data) => { + try { + ws.send(JSON.stringify({ event, data, timestamp: new Date().toISOString() })) + } catch (err) { + // Client disconnected, cleanup will be called + } + }) + + ws.on('close', cleanup) + ws.on('error', cleanup) + + // Send initial stats + try { + ws.send(JSON.stringify({ + event: 'connected', + data: { + stats: world.errorMonitor.getStats(), + recentErrors: world.errorMonitor.getErrors({ limit: 10 }) + }, + timestamp: new Date().toISOString() + })) + } catch (err) { + cleanup() + } +}) + fastify.setErrorHandler((err, req, reply) => { console.error(err) reply.status(500).send()