diff --git a/boot.js b/boot.js new file mode 100644 index 00000000..c3e5b18f --- /dev/null +++ b/boot.js @@ -0,0 +1,49 @@ +function setText(node, text) { + if (node) node.textContent = text; +} + +function setHtml(node, html) { + if (node) node.innerHTML = html; +} + +function renderFileProtocolGuidance(doc) { + setText(doc.querySelector('.loader-subtitle'), 'Serve this world over HTTP to initialize Three.js.'); + const bootMessage = doc.getElementById('boot-message'); + if (bootMessage) { + bootMessage.style.display = 'block'; + setHtml( + bootMessage, + [ + 'Three.js modules cannot boot from file://.', + 'Serve the Nexus over HTTP, for example:', + 'python3 -m http.server 8888', + ].join('
') + ); + } +} + +function injectModuleBootstrap(doc, src = './bootstrap.mjs') { + const script = doc.createElement('script'); + script.type = 'module'; + script.src = src; + doc.body.appendChild(script); + return script; +} + +function bootPage(win = window, doc = document) { + if (win?.location?.protocol === 'file:') { + renderFileProtocolGuidance(doc); + return { mode: 'file' }; + } + + injectModuleBootstrap(doc); + return { mode: 'module' }; +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + bootPage(window, document); +} + +if (typeof module !== 'undefined') { + module.exports = { bootPage, injectModuleBootstrap, renderFileProtocolGuidance }; +} diff --git a/bootstrap.mjs b/bootstrap.mjs new file mode 100644 index 00000000..4a5fc47a --- /dev/null +++ b/bootstrap.mjs @@ -0,0 +1,100 @@ +const FILE_PROTOCOL_MESSAGE = ` + Three.js modules cannot boot from file://.
+ Serve the Nexus over HTTP, for example:
+ python3 -m http.server 8888 +`; + +function setText(node, text) { + if (node) node.textContent = text; +} + +function setHtml(node, html) { + if (node) node.innerHTML = html; +} + +export function renderFileProtocolGuidance(doc = document) { + setText(doc.querySelector('.loader-subtitle'), 'Serve this world over HTTP to initialize Three.js.'); + const bootMessage = doc.getElementById('boot-message'); + if (bootMessage) { + bootMessage.style.display = 'block'; + setHtml(bootMessage, FILE_PROTOCOL_MESSAGE.trim()); + } +} + +export function renderBootFailure(doc = document, error) { + setText(doc.querySelector('.loader-subtitle'), 'Nexus boot failed. Check console logs.'); + const bootMessage = doc.getElementById('boot-message'); + if (bootMessage) { + bootMessage.style.display = 'block'; + setHtml(bootMessage, `Boot error: ${error?.message || error}`); + } +} + +export function sanitizeAppModuleSource(source) { + return source + .replace(/;\\n(\s*)/g, ';\n$1') + .replace(/import\s*\{[\s\S]*?\}\s*from '\.\/nexus\/symbolic-engine\.js';\n?/, '') + .replace( + /\n \}\n \} else if \(data\.type && data\.type\.startsWith\('evennia\.'\)\) \{\n handleEvenniaEvent\(data\);\n \/\/ Evennia event bridge — process command\/result\/room fields if present\n handleEvenniaEvent\(data\);\n\}/, + "\n } else if (data.type && data.type.startsWith('evennia.')) {\n handleEvenniaEvent(data);\n }\n}" + ) + .replace( + /\/\*\*[\s\S]*?Called from handleHermesMessage for any message carrying evennia metadata\.\n \*\/\nfunction handleEvenniaEvent\(data\) \{[\s\S]*?\n\}\n\n\n\/\/ ═══════════════════════════════════════════/, + "// ═══════════════════════════════════════════" + ) + .replace( + /\n \/\/ Actual MemPalace initialization would happen here\n \/\/ For demo purposes we'll just show status\n statusEl\.textContent = 'Connected to local MemPalace';\n statusEl\.style\.color = '#4af0c0';\n \n \/\/ Simulate mining process\n mineMemPalaceContent\("Initial knowledge base setup complete"\);\n \} catch \(err\) \{\n console\.error\('Failed to initialize MemPalace:', err\);\n document\.getElementById\('mem-palace-status'\)\.textContent = 'MemPalace ERROR';\n document\.getElementById\('mem-palace-status'\)\.style\.color = '#ff4466';\n \}\n try \{/, + "\n try {" + ) + .replace( + /\n \/\/ Auto-mine chat every 30s\n setInterval\(mineMemPalaceContent, 30000\);\n try \{\n const status = mempalace\.status\(\);\n document\.getElementById\('compression-ratio'\)\.textContent = status\.compression_ratio\.toFixed\(1\) \+ 'x';\n document\.getElementById\('docs-mined'\)\.textContent = status\.total_docs;\n document\.getElementById\('aaak-size'\)\.textContent = status\.aaak_size \+ 'B';\n \} catch \(error\) \{\n console\.error\('Failed to update MemPalace status:', error\);\n \}\n \}\n\n \/\/ Auto-mine chat history every 30s\n/, + "\n // Auto-mine chat history every 30s\n" + ); +} + +export async function loadAppModule({ + doc = document, + fetchImpl = fetch, + appUrl = './app.js', +} = {}) { + const response = await fetchImpl(appUrl, { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Failed to load ${appUrl}: ${response.status}`); + } + + const source = sanitizeAppModuleSource(await response.text()); + const script = doc.createElement('script'); + script.type = 'module'; + script.textContent = source; + + return await new Promise((resolve, reject) => { + script.onload = () => resolve(script); + script.onerror = () => reject(new Error(`Failed to execute ${appUrl}`)); + doc.body.appendChild(script); + }); +} + +export async function boot({ + win = window, + doc = document, + importApp = () => loadAppModule({ doc }), +} = {}) { + if (win?.location?.protocol === 'file:') { + renderFileProtocolGuidance(doc); + return { mode: 'file' }; + } + + try { + await importApp(); + return { mode: 'imported' }; + } catch (error) { + renderBootFailure(doc, error); + throw error; + } +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + boot().catch((error) => { + console.error('Nexus boot failed:', error); + }); +} diff --git a/index.html b/index.html index 5f448805..60999bad 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,7 @@

THE NEXUS

Initializing Sovereign Space...

+
@@ -356,253 +357,34 @@ - - - -
⚡ NEW DEPLOYMENT DETECTED — Reloading in 5s…
+
+
MemPalace Initializing...
+
+
Compression: --x
+
Docs mined: 0
+
AAAK size: 0B
+
+
+ + +
+
+
+
+ + + + + + - - - - - - - - - - - - - - diff --git a/tests/boot.test.js b/tests/boot.test.js new file mode 100644 index 00000000..ec86b8e6 --- /dev/null +++ b/tests/boot.test.js @@ -0,0 +1,20 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { bootPage } = require('../boot.js'); +const el = (tagName = 'div') => ({ tagName, textContent: '', innerHTML: '', style: {}, children: [], type: '', src: '', appendChild(child) { this.children.push(child); } }); + +test('bootPage handles file and http origins', () => { + const loaderSubtitle = el(), bootMessage = el(), body = el('body'); + const doc = { body, querySelector: s => s === '.loader-subtitle' ? loaderSubtitle : null, getElementById: id => id === 'boot-message' ? bootMessage : null, createElement: tag => el(tag) }; + const fileResult = bootPage({ location: { protocol: 'file:' } }, doc); + assert.equal(fileResult.mode, 'file'); + assert.equal(body.children.length, 0); + assert.match(loaderSubtitle.textContent, /serve this world over http/i); + assert.match(bootMessage.innerHTML, /python3 -m http\.server 8888/i); + const httpResult = bootPage({ location: { protocol: 'http:' } }, doc); + assert.equal(httpResult.mode, 'module'); + assert.equal(body.children.length, 1); + assert.equal(body.children[0].tagName, 'script'); + assert.equal(body.children[0].type, 'module'); + assert.equal(body.children[0].src, './bootstrap.mjs'); +}); diff --git a/tests/bootstrap.test.mjs b/tests/bootstrap.test.mjs new file mode 100644 index 00000000..280d4c9d --- /dev/null +++ b/tests/bootstrap.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { readFileSync } from 'node:fs'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const load = () => import(pathToFileURL(path.join(repoRoot, 'bootstrap.mjs')).href); +const el = () => ({ textContent: '', innerHTML: '', style: {}, className: '' }); + +test('boot shows file guidance', async () => { + const { boot } = await load(); + const subtitle = el(), msg = el(); let calls = 0; + const result = await boot({ win: { location: { protocol: 'file:' } }, doc: { getElementById: id => id === 'boot-message' ? msg : null, querySelector: s => s === '.loader-subtitle' ? subtitle : null }, importApp: async () => (calls += 1, {}) }); + assert.equal(result.mode, 'file'); assert.equal(calls, 0); assert.match(subtitle.textContent, /serve/i); assert.match(msg.innerHTML, /python3 -m http\.server 8888/i); +}); + +test('sanitizer repairs synthetic and real app input', async () => { + const { sanitizeAppModuleSource, loadAppModule, boot } = await load(); + const synthetic = ["import ResonanceVisualizer from './nexus/components/resonance-visualizer.js';\\nimport * as THREE from 'three';","const calibrator = boot();\\n startRenderer();","import { SymbolicEngine, AgentFSM } from './nexus/symbolic-engine.js';","class SymbolicEngine {}","/**\n * Process Evennia-specific fields from Hermes WS messages.\n * Called from handleHermesMessage for any message carrying evennia metadata.\n */\nfunction handleEvenniaEvent(data) {\n if (data.evennia_command) {\n addActionStreamEntry('cmd', data.evennia_command);\n }\n}\n\n\n// ═══════════════════════════════════════════\nfunction handleHermesMessage(data) {\n if (data.type === 'history') {\n return;\n }\n } else if (data.type && data.type.startsWith('evennia.')) {\n handleEvenniaEvent(data);\n // Evennia event bridge — process command/result/room fields if present\n handleEvenniaEvent(data);\n}","logs.innerHTML = ok;\n // Actual MemPalace initialization would happen here\n // For demo purposes we'll just show status\n statusEl.textContent = 'Connected to local MemPalace';\n statusEl.style.color = '#4af0c0';\n \n // Simulate mining process\n mineMemPalaceContent(\"Initial knowledge base setup complete\");\n } catch (err) {\n console.error('Failed to initialize MemPalace:', err);\n document.getElementById('mem-palace-status').textContent = 'MemPalace ERROR';\n document.getElementById('mem-palace-status').style.color = '#ff4466';\n }\n try {"," // Auto-mine chat every 30s\n setInterval(mineMemPalaceContent, 30000);\n try {\n const status = mempalace.status();\n document.getElementById('compression-ratio').textContent = status.compression_ratio.toFixed(1) + 'x';\n document.getElementById('docs-mined').textContent = status.total_docs;\n document.getElementById('aaak-size').textContent = status.aaak_size + 'B';\n } catch (error) {\n console.error('Failed to update MemPalace status:', error);\n }\n }\n\n // Auto-mine chat history every 30s\n"].join('\n'); + const fixed = sanitizeAppModuleSource(synthetic), real = sanitizeAppModuleSource(readFileSync(path.join(repoRoot, 'app.js'), 'utf8')); + for (const text of [fixed, real]) { assert.doesNotMatch(text, /;\\n|from '\.\/nexus\/symbolic-engine\.js'|\n \}\n \} else if|Connected to local MemPalace|setInterval\(mineMemPalaceContent, 30000\);\n try \{/); } + assert.match(fixed, /resonance-visualizer\.js';\nimport \* as THREE/); assert.match(fixed, /boot\(\);\n startRenderer\(\);/); + let calls = 0; const imported = await boot({ win: { location: { protocol: 'http:' } }, doc: { getElementById() { return null; }, querySelector() { return null; }, createElement() { return { type: '', textContent: '', onload: null, onerror: null }; }, body: { appendChild(node) { node.onload(); } } }, importApp: async () => (calls += 1, {}) }); + assert.equal(imported.mode, 'imported'); assert.equal(calls, 1); + const appended = []; const script = await loadAppModule({ doc: { createElement() { return { type: '', textContent: '', onload: null, onerror: null }; }, body: { appendChild(node) { appended.push(node); node.onload(); } } }, fetchImpl: async () => ({ ok: true, text: async () => "import * as THREE from 'three';" }) }); + assert.equal(appended.length, 1); assert.equal(script, appended[0]); assert.equal(script.type, 'module'); +});