Monorepo for Aesthetic.Computer
aesthetic.computer
WebSocket Module Loader#
Problem#
On localhost, the HTTP proxy chain (Caddy → Netlify Dev → browser) causes:
ERR_CONTENT_LENGTH_MISMATCHerrorsERR_INCOMPLETE_CHUNKED_ENCODINGerrors- Slow module loading due to proxy overhead
- Unreliable hot reloading
Current workarounds in Caddyfile:
- Disabled compression (
encode zstd gzipcommented out) - Strip
Accept-Encodingheader - Force
Connection: close
These help but don't fully solve the issue.
Solution: WebSocket Module Streaming#
Establish an early WebSocket connection to session-server and use it to:
- Stream JS module text directly to the client
- Cache modules locally (IndexedDB/Cache API)
- Serve cached modules on subsequent loads
- Prefetch modules in parallel with execution
Architecture#
┌─────────────┐ ┌──────────────────┐
│ boot.mjs │───WebSocket───────▶│ session-server │
│ (early) │ │ │
└─────────────┘ │ /ws/modules │
│ │ - disk.mjs │
▼ │ - bios.mjs │
┌─────────────┐ │ - graph.mjs │
│ IndexedDB │ │ - ... │
│ Module Cache│ └──────────────────┘
└─────────────┘
│
▼
┌─────────────┐
│ Blob URL │ URL.createObjectURL(new Blob([moduleText]))
│ Import │ import(blobUrl)
└─────────────┘
Implementation Plan#
Phase 1: Session Server Module Endpoint#
File: session-server/session.mjs
Add a module streaming protocol:
// New message types
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'module:request') {
// Request: { type: 'module:request', path: 'lib/disk.mjs' }
const modulePath = path.join(PUBLIC_DIR, 'aesthetic.computer', msg.path);
const content = fs.readFileSync(modulePath, 'utf8');
const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
ws.send(JSON.stringify({
type: 'module:response',
path: msg.path,
hash,
content
}));
}
if (msg.type === 'module:check') {
// Check if module changed: { type: 'module:check', path: 'lib/disk.mjs', hash: '...' }
const modulePath = path.join(PUBLIC_DIR, 'aesthetic.computer', msg.path);
const content = fs.readFileSync(modulePath, 'utf8');
const currentHash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
ws.send(JSON.stringify({
type: 'module:status',
path: msg.path,
changed: currentHash !== msg.hash,
hash: currentHash
}));
}
});
Phase 2: Boot.mjs Early Connection#
File: system/public/aesthetic.computer/boot.mjs
// Very early - before any other imports
const MODULE_CACHE_NAME = 'ac-modules-v1';
const SESSION_WS_URL = location.hostname === 'localhost'
? 'ws://localhost:8889'
: 'wss://session-server.aesthetic.computer';
class ModuleLoader {
constructor() {
this.ws = null;
this.cache = null;
this.pending = new Map(); // path -> Promise resolvers
this.modules = new Map(); // path -> { hash, blobUrl }
}
async init() {
// Open IndexedDB cache
this.cache = await this.openCache();
// Connect WebSocket
this.ws = new WebSocket(SESSION_WS_URL);
return new Promise((resolve, reject) => {
this.ws.onopen = () => {
this.ws.onmessage = (e) => this.handleMessage(JSON.parse(e.data));
resolve();
};
this.ws.onerror = reject;
setTimeout(() => reject(new Error('WS timeout')), 5000);
});
}
async openCache() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('ac-module-cache', 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
db.createObjectStore('modules', { keyPath: 'path' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = reject;
});
}
handleMessage(msg) {
if (msg.type === 'module:response') {
const resolver = this.pending.get(msg.path);
if (resolver) {
// Create blob URL
const blob = new Blob([msg.content], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
// Cache it
this.modules.set(msg.path, { hash: msg.hash, blobUrl });
this.cacheModule(msg.path, msg.hash, msg.content);
resolver.resolve(blobUrl);
this.pending.delete(msg.path);
}
}
if (msg.type === 'module:status') {
const resolver = this.pending.get(`check:${msg.path}`);
if (resolver) {
resolver.resolve(msg);
this.pending.delete(`check:${msg.path}`);
}
}
}
async load(path) {
// Check local cache first
const cached = await this.getCached(path);
if (cached) {
// Verify hash in background
this.checkHash(path, cached.hash);
return cached.blobUrl;
}
// Request from server
return new Promise((resolve, reject) => {
this.pending.set(path, { resolve, reject });
this.ws.send(JSON.stringify({ type: 'module:request', path }));
setTimeout(() => reject(new Error(`Module timeout: ${path}`)), 10000);
});
}
async getCached(path) {
return new Promise((resolve) => {
const tx = this.cache.transaction('modules', 'readonly');
const req = tx.objectStore('modules').get(path);
req.onsuccess = () => {
if (req.result) {
const blob = new Blob([req.result.content], { type: 'application/javascript' });
resolve({ hash: req.result.hash, blobUrl: URL.createObjectURL(blob) });
} else {
resolve(null);
}
};
req.onerror = () => resolve(null);
});
}
cacheModule(path, hash, content) {
const tx = this.cache.transaction('modules', 'readwrite');
tx.objectStore('modules').put({ path, hash, content });
}
async checkHash(path, cachedHash) {
// Check if server version changed
return new Promise((resolve) => {
this.pending.set(`check:${path}`, { resolve });
this.ws.send(JSON.stringify({ type: 'module:check', path, hash: cachedHash }));
});
}
// Prefetch modules we know we'll need
prefetch(paths) {
for (const path of paths) {
if (!this.modules.has(path) && !this.pending.has(path)) {
this.load(path).catch(() => {}); // Fire and forget
}
}
}
}
// Global instance
window.acModuleLoader = new ModuleLoader();
// Export for use in other modules
export { ModuleLoader };
export const moduleLoader = window.acModuleLoader;
Phase 3: Integration with Boot Sequence#
File: system/public/aesthetic.computer/boot.mjs (updated)
// At the very top of boot.mjs
import { moduleLoader } from './module-loader.mjs';
async function boot() {
// 1. Initialize module loader (WebSocket + IndexedDB)
try {
await moduleLoader.init();
console.log('🔌 Module loader connected');
// 2. Prefetch critical modules immediately
moduleLoader.prefetch([
'lib/disk.mjs',
'lib/graph.mjs',
'lib/num.mjs',
'lib/geo.mjs',
'lib/parse.mjs',
'lib/help.mjs',
'bios.mjs'
]);
} catch (e) {
console.warn('⚠️ Module loader failed, falling back to HTTP:', e);
// Fall back to normal HTTP imports
}
// 3. Load disk.mjs (will use cache if available)
const diskUrl = await moduleLoader.load('lib/disk.mjs').catch(() => './lib/disk.mjs');
const { boot: diskBoot } = await import(diskUrl);
// ... rest of boot sequence
}
Phase 4: Hot Reload via WebSocket#
Session server can push module updates:
// session-server: Watch for file changes
const watcher = fs.watch(PUBLIC_DIR, { recursive: true }, (eventType, filename) => {
if (filename.endsWith('.mjs') || filename.endsWith('.js')) {
const relativePath = filename.replace(/\\/g, '/');
const content = fs.readFileSync(path.join(PUBLIC_DIR, filename), 'utf8');
const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
// Broadcast to all connected clients
broadcast({
type: 'module:updated',
path: relativePath,
hash
});
}
});
// Client side: Listen for updates
moduleLoader.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'module:updated') {
// Invalidate cache
moduleLoader.modules.delete(msg.path);
// Optionally trigger hot reload
if (window.acHotReload) {
window.acHotReload(msg.path);
}
}
};
Benefits#
Development#
- Bypasses proxy chain - Direct WebSocket to session-server, no Caddy/Netlify issues
- No more
ERR_CONTENT_LENGTH_MISMATCH- WebSocket is a clean binary channel - Hot reload - Push module updates instantly via existing connection
- Faster iteration - Module changes arrive in milliseconds
Production#
- Persistent connection - No TCP/TLS handshake per module (already connected for real-time)
- Parallel prefetching - Download next modules while current ones execute
- Local caching - Instant loads after first visit (IndexedDB survives refresh)
- Hash validation - Know when cache is stale, only re-download changed modules
- Single connection - Reuse for module loading + real-time features + UDP setup
Both#
- Graceful fallback - If WebSocket is slow/offline, HTTP works exactly as before
- Progressive enhancement - Zero breakage, only speed improvements
- Transparent - Code doesn't need to know where modules came from
Fallback Strategy#
The loader races WebSocket against a timeout:
async load(path) {
// Race: WebSocket vs timeout
const wsPromise = this.loadViaWebSocket(path);
const timeoutMs = 500; // Half second max wait
try {
return await Promise.race([
wsPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('WS slow')), timeoutMs)
)
]);
} catch {
// WebSocket slow or failed - use normal HTTP
console.log(`⚡ Falling back to HTTP for ${path}`);
return `./${path}`; // Normal relative import path
}
}
This means:
- WebSocket fast (< 500ms): Use cached/streamed module ✅
- WebSocket slow (> 500ms): Fall back to HTTP, no delay ✅
- WebSocket offline: Immediate fallback to HTTP ✅
- Cached locally: Instant, no network at all ✅
Migration Path#
- Phase 1: Add module endpoint to session-server (no client changes)
- Phase 2: Add ModuleLoader class to boot.mjs with HTTP fallback
- Phase 3: Gradually migrate critical modules to use loader
- Phase 4: Add hot reload support
- Phase 5: Consider for production (with CDN cache headers)
Considerations#
- CORS: WebSocket doesn't have CORS issues
- Binary transfer: Could use binary WebSocket frames for larger modules
- Compression: WebSocket can use per-message deflate
- Fallback: Always fall back to HTTP if WebSocket fails
- Production: Could still be useful for faster initial load + hot reload
File Changes#
| File | Change |
|---|---|
session-server/session.mjs |
Add module streaming handlers |
system/public/aesthetic.computer/module-loader.mjs |
New file - ModuleLoader class |
system/public/aesthetic.computer/boot.mjs |
Integrate ModuleLoader early |
system/public/aesthetic.computer/lib/disk.mjs |
Use moduleLoader for dynamic imports |
Status#
- Phase 1: Session server module endpoint
- Phase 2: ModuleLoader class
- Phase 3: Boot.mjs integration
- Phase 4: Hot reload
- Phase 5: Production evaluation