Monorepo for Aesthetic.Computer aesthetic.computer
at main 3958 lines 135 kB view raw
1// Session Server, 23.12.04.14.57 2// Represents a "room" or user or "client" backend 3// which at the moment is run once for every "piece" 4// that requests it. 5 6/* #region todo 7 + Now 8 - [-] Fix live reloading of in-production udp. 9 + Done 10 - [c] `code.channel` should return a promise, and wait for a 11 `code-channel:subbed`. 12 event here? This way users get better confirmation if the socket 13 doesn't go through or if there is a server issue. 23.07.04.18.01 14 (Might not actually be that necessary.) 15 - [x] Add `obscenity` filter. 16 - [x] Conditional redis sub to dev updates. (Will save bandwidth if extension 17 gets lots of use, also would be more secure.) 18 - [x] Secure the "code" path to require a special string. 19 - [x] Secure the "reload" path (must be in dev mode, sorta okay) 20 - [c] Speed up developer reload by using redis pub/sub. 21 - [x] Send a signal to everyone once a user leaves. 22 - [x] Get "developer" live reloading working again. 23 - [x] Add sockets back. 24 - [x] Make a "local" option. 25 - [x] Read through: https://redis.io/docs/data-types 26#endregion */ 27 28// Add redis pub/sub here... 29 30import Fastify from "fastify"; 31import geckos from "@geckos.io/server"; 32import geoip from "geoip-lite"; 33import { WebSocket, WebSocketServer } from "ws"; 34import ip from "ip"; 35import chokidar from "chokidar"; 36import fs from "fs"; 37import path from "path"; 38import crypto from "crypto"; 39import dotenv from "dotenv"; 40import dgram from "dgram"; 41dotenv.config(); 42 43// Module streaming - path to public directory 44const PUBLIC_DIR = path.resolve(process.cwd(), "../system/public/aesthetic.computer"); 45 46// Module hash cache (invalidated on file change) 47const moduleHashes = new Map(); // path -> { hash, content, mtime } 48 49// Compute hash for a module file 50function getModuleHash(modulePath) { 51 const fullPath = path.join(PUBLIC_DIR, modulePath); 52 try { 53 const stats = fs.statSync(fullPath); 54 const cached = moduleHashes.get(modulePath); 55 56 // Return cached if mtime matches 57 if (cached && cached.mtime === stats.mtimeMs) { 58 return cached; 59 } 60 61 // Read and hash 62 const content = fs.readFileSync(fullPath, "utf8"); 63 const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 16); 64 const entry = { hash, content, mtime: stats.mtimeMs }; 65 moduleHashes.set(modulePath, entry); 66 return entry; 67 } catch (err) { 68 return null; 69 } 70} 71 72// Fairy:point throttle (for silo firehose visualization) 73const fairyThrottle = new Map(); // channelId -> last publish timestamp 74const FAIRY_THROTTLE_MS = 100; // 10Hz max per connection 75 76// Raw UDP fairy relay (for native bare-metal clients) 77const udpRelay = dgram.createSocket("udp4"); 78const udpClients = new Map(); // key "ip:port" → { address, port, handle, lastSeen } 79const UDP_MIDI_SOURCE_TTL_MS = 20000; 80const notepatMidiSources = new Map(); // key "@handle:machine" -> source metadata 81const notepatMidiSubscribers = new Map(); // connection id -> { ws, all, handle, machineId } 82 83// Error logging ring buffer (for dashboard display) 84const errorLog = []; 85const MAX_ERRORS = 50; 86const ERROR_RETENTION_MS = 60 * 60 * 1000; // 1 hour 87 88function logError(level, message) { 89 const entry = { 90 level, 91 message: typeof message === 'string' ? message : JSON.stringify(message), 92 timestamp: new Date().toISOString() 93 }; 94 errorLog.push(entry); 95 if (errorLog.length > MAX_ERRORS) errorLog.shift(); 96} 97 98// Capture uncaught errors 99process.on('uncaughtException', (err) => { 100 logError('error', `Uncaught: ${err.message}`); 101 console.error('Uncaught Exception:', err); 102}); 103 104process.on('unhandledRejection', (reason, promise) => { 105 logError('error', `Unhandled Rejection: ${reason}`); 106 console.error('Unhandled Rejection:', reason); 107}); 108 109import { exec } from "child_process"; 110 111// FCM (Firebase Cloud Messaging) 112import { initializeApp, cert } from "firebase-admin/app"; // Firebase notifications. 113//import serviceAccount from "./aesthetic-computer-firebase-adminsdk-79w8j-5b5cdfced8.json" assert { type: "json" }; 114import { getMessaging } from "firebase-admin/messaging"; 115 116let serviceAccount; 117try { 118 const response = await fetch(process.env.GCM_FIREBASE_CONFIG_URL); 119 if (!response.ok) { 120 throw new Error(`HTTP error! Status: ${response.status}`); 121 } 122 serviceAccount = await response.json(); 123} catch (error) { 124 console.error("Error fetching service account:", error); 125 // Handle the error as needed 126} 127 128initializeApp( 129 { credential: cert(serviceAccount) }, //, 130 //"aesthetic" + ~~performance.now(), 131); 132 133// Initialize ChatManager for multi-instance chat support 134const chatManager = new ChatManager({ dev: process.env.NODE_ENV === "development" }); 135await chatManager.init(); 136 137// Graceful shutdown — persist in-memory chat messages before exit 138let shuttingDown = false; 139async function gracefulShutdown(signal) { 140 if (shuttingDown) return; 141 shuttingDown = true; 142 console.log(`\n${signal} received, persisting chat messages...`); 143 try { 144 await chatManager.shutdown(); 145 } catch (err) { 146 console.error("Shutdown error:", err); 147 } 148 process.exit(0); 149} 150process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); 151process.on("SIGINT", () => gracefulShutdown("SIGINT")); 152 153// Helper function to get handles of users currently on a specific piece 154// Used by chatManager to determine who's actually viewing the chat piece 155function getHandlesOnPiece(pieceName) { 156 const handles = []; 157 for (const [id, client] of Object.entries(clients)) { 158 if (client.location === pieceName && client.handle) { 159 handles.push(client.handle); 160 } 161 } 162 return [...new Set(handles)]; // Remove duplicates 163} 164 165// Expose the function to chatManager 166chatManager.setPresenceResolver(getHandlesOnPiece); 167 168// 🎯 Duel Manager — server-authoritative game for dumduel piece 169const duelManager = new DuelManager(); 170 171import { filter } from "./filter.mjs"; // Profanity filtering. 172import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support. 173import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. 174 175// *** AC Machines — remote device monitoring *** 176// Devices connect via /machines?role=device&machineId=X&token=Y 177// Viewers connect via /machines?role=viewer&token=Y 178import { MongoClient } from "mongodb"; 179 180const machinesDevices = new Map(); // machineId → { ws, user, handle, machineId, info, lastHeartbeat } 181const machinesViewers = new Map(); // userSub → Set<ws> 182let machinesDb = null; 183 184async function getMachinesDb() { 185 if (machinesDb) return machinesDb; 186 const connStr = process.env.MONGODB_CONNECTION_STRING; 187 if (!connStr) return null; 188 try { 189 const client = new MongoClient(connStr); 190 await client.connect(); 191 machinesDb = client.db(process.env.MONGODB_NAME || "aesthetic"); 192 return machinesDb; 193 } catch (e) { 194 error("[machines] MongoDB connect error:", e.message); 195 return null; 196 } 197} 198 199let machineTokenSecret = null; 200let machineTokenSecretAt = 0; 201const MACHINE_SECRET_TTL = 5 * 60 * 1000; // refresh from DB every 5 min 202 203async function loadMachineTokenSecret() { 204 const now = Date.now(); 205 if (machineTokenSecret && now - machineTokenSecretAt < MACHINE_SECRET_TTL) { 206 return machineTokenSecret; 207 } 208 try { 209 const db = await getMachinesDb(); 210 if (!db) return machineTokenSecret; 211 const doc = await db.collection("secrets").findOne({ _id: "machine-token" }); 212 if (doc?.secret) { 213 machineTokenSecret = doc.secret; 214 machineTokenSecretAt = now; 215 } 216 } catch (e) { 217 error("[machines] Failed to load machine-token secret:", e.message); 218 } 219 return machineTokenSecret; 220} 221 222async function verifyMachineToken(token) { 223 if (!token) return null; 224 const secret = await loadMachineTokenSecret(); 225 if (!secret) return null; 226 try { 227 const [payloadB64, sigB64] = token.split("."); 228 if (!payloadB64 || !sigB64) return null; 229 const expectedSig = crypto 230 .createHmac("sha256", secret) 231 .update(payloadB64) 232 .digest("base64url"); 233 if (sigB64 !== expectedSig) return null; 234 return JSON.parse(Buffer.from(payloadB64, "base64url").toString()); 235 } catch { 236 return null; 237 } 238} 239 240// Verify an AC auth token (Bearer token from authorize()) by calling Auth0 userinfo 241async function verifyACToken(token) { 242 if (!token) return null; 243 try { 244 const res = await fetch("https://aesthetic.us.auth0.com/userinfo", { 245 headers: { Authorization: `Bearer ${token}` }, 246 }); 247 if (!res.ok) return null; 248 return await res.json(); // { sub, nickname, name, ... } 249 } catch { 250 return null; 251 } 252} 253 254function broadcastToMachineViewers(userSub, msg) { 255 const viewers = machinesViewers.get(userSub); 256 if (!viewers) return; 257 const data = JSON.stringify(msg); 258 for (const v of viewers) { 259 if (v.readyState === WebSocket.OPEN) v.send(data); 260 } 261} 262 263async function upsertMachine(userSub, machineId, info) { 264 const db = await getMachinesDb(); 265 if (!db) return; 266 const col = db.collection("ac-machines"); 267 const now = new Date(); 268 await col.updateOne( 269 { user: userSub, machineId }, 270 { 271 $set: { 272 user: userSub, 273 machineId, 274 ...info, 275 status: "online", 276 linked: true, 277 lastSeen: now, 278 updatedAt: now, 279 }, 280 $setOnInsert: { createdAt: now, bootCount: 0 }, 281 $inc: { bootCount: 1 }, 282 }, 283 { upsert: true }, 284 ); 285} 286 287async function updateMachineHeartbeat(userSub, machineId, uptime, currentPiece) { 288 const db = await getMachinesDb(); 289 if (!db) return; 290 await db.collection("ac-machines").updateOne( 291 { user: userSub, machineId }, 292 { $set: { lastSeen: new Date(), uptime, currentPiece, status: "online" } }, 293 ); 294} 295 296async function insertMachineLog(userSub, machineId, msg) { 297 const db = await getMachinesDb(); 298 if (!db) return; 299 await db.collection("ac-machine-logs").insertOne({ 300 machineId, 301 user: userSub, 302 type: msg.logType || "log", 303 level: msg.level || "info", 304 message: msg.message, 305 data: msg.data || null, 306 crashInfo: msg.crashInfo || null, 307 when: msg.when ? new Date(msg.when) : new Date(), 308 receivedAt: new Date(), 309 }); 310} 311 312async function setMachineOffline(userSub, machineId) { 313 const db = await getMachinesDb(); 314 if (!db) return; 315 await db.collection("ac-machines").updateOne( 316 { user: userSub, machineId }, 317 { $set: { status: "offline", updatedAt: new Date() } }, 318 ); 319} 320 321// *** SockLogs - Remote console log forwarding from devices *** 322// Devices with ?socklogs param send logs via WebSocket 323// Viewers (CLI or web) can subscribe to see device logs in real-time 324const socklogsDevices = new Map(); // deviceId -> { ws, lastLog, logCount } 325const socklogsViewers = new Set(); // Set of viewer WebSockets 326 327function socklogsBroadcast(deviceId, logEntry) { 328 const message = JSON.stringify({ 329 type: 'log', 330 deviceId, 331 ...logEntry, 332 serverTime: Date.now() 333 }); 334 for (const viewer of socklogsViewers) { 335 if (viewer.readyState === WebSocket.OPEN) { 336 viewer.send(message); 337 } 338 } 339} 340 341function socklogsStatus() { 342 return { 343 devices: Array.from(socklogsDevices.entries()).map(([id, info]) => ({ 344 deviceId: id, 345 logCount: info.logCount, 346 lastLog: info.lastLog, 347 connectedAt: info.connectedAt 348 })), 349 viewerCount: socklogsViewers.size 350 }; 351} 352 353import { createClient } from "redis"; 354const redisConnectionString = process.env.REDIS_CONNECTION_STRING; 355const dev = process.env.NODE_ENV === "development"; 356 357// Dev log file for remote debugging 358const DEV_LOG_FILE = path.join(process.cwd(), "../system/public/aesthetic.computer/dev-logs.txt"); 359 360const { keys } = Object; 361let fastify; //, termkit, term; 362 363if (dev) { 364 // Load local ssl certs in development mode. 365 fastify = Fastify({ 366 https: { 367 // allowHTTP1: true, 368 key: fs.readFileSync("../ssl-dev/localhost-key.pem"), 369 cert: fs.readFileSync("../ssl-dev/localhost.pem"), 370 }, 371 logger: true, 372 }); 373 374 // Import the `terminal-kit` library if dev is true. 375 // try { 376 // termkit = (await import("terminal-kit")).default; 377 // } catch (err) { 378 // error("Failed to load terminal-kit", error); 379 // } 380} else { 381 fastify = Fastify({ logger: true }); // Still log in production. No reason not to? 382} 383 384// Insert `cors` headers as needed. 23.12.19.16.31 385// TODO: Is this even necessary? 386fastify.options("*", async (req, reply) => { 387 const allowedOrigins = [ 388 "https://aesthetic.local:8888", 389 "https://aesthetic.computer", 390 "https://notepat.com", 391 ]; 392 393 const origin = req.headers.origin; 394 log("✈️ Preflight origin:", origin); 395 // Check if the incoming origin is allowed 396 if (allowedOrigins.includes(origin)) { 397 reply.header("Access-Control-Allow-Origin", origin); 398 } 399 reply.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); 400 reply.send(); 401}); 402 403const server = fastify.server; 404 405const DEV_LOG_DIR = "/tmp/dev-logs/"; 406const deviceLogFiles = new Map(); // Track which devices have log files 407 408// Ensure log directory exists 409if (dev) { 410 try { 411 fs.mkdirSync(DEV_LOG_DIR, { recursive: true }); 412 } catch (error) { 413 console.error("Failed to create dev log directory:", error); 414 } 415} 416 417const info = { 418 port: process.env.PORT, // 8889 in development via `package.json` 419 name: process.env.SESSION_BACKEND_ID, 420 service: process.env.JAMSOCKET_SERVICE, 421}; 422 423const codeChannels = {}; // Used to filter `code` updates from redis to 424// clients who explicitly have the channel set. 425const codeChannelState = {}; // Store last code sent to each channel for late joiners 426 427// DAW channel for M4L device ↔ IDE communication 428const dawDevices = new Set(); // Connection IDs of /device instances 429const dawIDEs = new Set(); // Connection IDs of IDE instances in Ableton mode 430 431// Unified client tracking: each client has handle, user, location, and connection types 432const clients = {}; // Map of connection ID to { handle, user, location, websocket: true/false, udp: true/false } 433 434// Device naming for local dev (persisted to file) 435const DEVICE_NAMES_FILE = path.join(process.cwd(), "../.device-names.json"); 436let deviceNames = {}; // Map of IP -> { name, group } 437function loadDeviceNames() { 438 try { 439 if (fs.existsSync(DEVICE_NAMES_FILE)) { 440 deviceNames = JSON.parse(fs.readFileSync(DEVICE_NAMES_FILE, 'utf8')); 441 log("📱 Loaded device names:", Object.keys(deviceNames).length); 442 } 443 } catch (e) { 444 log("📱 Could not load device names:", e.message); 445 } 446} 447function saveDeviceNames() { 448 try { 449 fs.writeFileSync(DEVICE_NAMES_FILE, JSON.stringify(deviceNames, null, 2)); 450 } catch (e) { 451 log("📱 Could not save device names:", e.message); 452 } 453} 454if (dev) loadDeviceNames(); 455 456// Get the dev host machine name 457import os from "os"; 458const DEV_HOST_NAME = os.hostname(); 459const DEV_LAN_IP = (() => { 460 // First, try to read from /tmp/host-lan-ip (written by entry.fish in devcontainer) 461 try { 462 const hostIpFile = '/tmp/host-lan-ip'; 463 if (fs.existsSync(hostIpFile)) { 464 const ip = fs.readFileSync(hostIpFile, 'utf-8').trim(); 465 if (ip && ip.match(/^\d+\.\d+\.\d+\.\d+$/)) { 466 console.log(`🖥️ Using host LAN IP from ${hostIpFile}: ${ip}`); 467 return ip; 468 } 469 } 470 } catch (e) { /* ignore */ } 471 472 // Fallback: try to detect from network interfaces 473 const interfaces = os.networkInterfaces(); 474 for (const name of Object.keys(interfaces)) { 475 for (const iface of interfaces[name]) { 476 if (iface.family === 'IPv4' && !iface.internal && iface.address.startsWith('192.168.')) { 477 return iface.address; 478 } 479 } 480 } 481 return null; 482})(); 483console.log(`🖥️ Dev host: ${DEV_HOST_NAME}, LAN IP: ${DEV_LAN_IP || 'N/A'}`); 484 485// Helper: Assign device letters (A, B, C...) based on connection order 486function getDeviceLetter(connectionId) { 487 // Get sorted list of connection IDs 488 const sortedIds = Object.keys(connections) 489 .map(id => parseInt(id)) 490 .sort((a, b) => a - b); 491 const index = sortedIds.indexOf(parseInt(connectionId)); 492 if (index === -1) return '?'; 493 // A=65, B=66, etc. Wrap around after Z 494 return String.fromCharCode(65 + (index % 26)); 495} 496 497// Helper: Find connections by ID, IP, handle, or device letter 498function targetClients(target) { 499 if (target === 'all') { 500 return Object.entries(connections) 501 .filter(([id, ws]) => ws?.readyState === WebSocket.OPEN) 502 .map(([id, ws]) => ({ id: parseInt(id), ws })); 503 } 504 505 const results = []; 506 for (const [id, ws] of Object.entries(connections)) { 507 const client = clients[id]; 508 const cleanTarget = target.replace('@', ''); 509 const cleanIp = client?.ip?.replace('::ffff:', ''); 510 const deviceLetter = getDeviceLetter(id); 511 512 if ( 513 String(id) === String(target) || 514 cleanIp === target || 515 client?.handle === `@${cleanTarget}` || 516 client?.handle === cleanTarget || 517 deviceNames[cleanIp]?.name?.toLowerCase() === target.toLowerCase() || 518 deviceLetter.toLowerCase() === target.toLowerCase() // Match by letter (A, B, C...) 519 ) { 520 if (ws?.readyState === WebSocket.OPEN) { 521 results.push({ id: parseInt(id), ws }); 522 } 523 } 524 } 525 return results; 526} 527 528// *** Start up two `redis` clients. (One for subscribing, and for publishing) 529const redisEnabled = !!redisConnectionString; 530const sub = redisEnabled 531 ? (!dev ? createClient({ url: redisConnectionString }) : createClient()) 532 : null; 533if (sub) sub.on("error", (err) => { 534 log("🔴 Redis subscriber client error!", err); 535 logError('error', `Redis sub: ${err.message}`); 536}); 537 538const pub = redisEnabled 539 ? (!dev ? createClient({ url: redisConnectionString }) : createClient()) 540 : null; 541if (pub) pub.on("error", (err) => { 542 log("🔴 Redis publisher client error!", err); 543 logError('error', `Redis pub: ${err.message}`); 544}); 545 546try { 547 if (sub && pub) { 548 await sub.connect(); 549 await pub.connect(); 550 551 await sub.subscribe("code", (message) => { 552 const parsed = JSON.parse(message); 553 if (codeChannels[parsed.codeChannel]) { 554 const msg = pack("code", message, "development"); 555 subscribers(codeChannels[parsed.codeChannel], msg); 556 } 557 }); 558 559 await sub.subscribe("scream", (message) => { 560 everyone(pack("scream", message, "screamer")); // Socket back to everyone. 561 }); 562 } else { 563 log("⚠️ Redis disabled — code/scream channels unavailable"); 564 } 565} catch (err) { 566 error("🔴 Could not connect to `redis` instance."); 567} 568 569const secret = process.env.GITHUB_WEBHOOK_SECRET; 570 571fastify.post("/update", (request, reply) => { 572 const signature = request.headers["x-hub-signature"]; 573 const hash = 574 "sha1=" + 575 crypto 576 .createHmac("sha1", secret) 577 .update(JSON.stringify(request.body)) 578 .digest("hex"); 579 580 if (hash !== signature) { 581 reply.status(401).send({ error: "Invalid signature" }); 582 return; 583 } 584 585 // log("Path:", process.env.PATH); 586 587 // Restart service in production. 588 // exec( 589 // "cd /home/aesthetic-computer/session-server; pm2 stop all; git pull; npm install; pm2 start all", 590 // (err, stdout, stderr) => { 591 // if (err) { 592 // error(`exec error: ${error}`); 593 // return; 594 // } 595 // log(`stdout: ${stdout}`); 596 // error(`stderr: ${stderr}`); 597 // }, 598 // ); 599 600 reply.send({ status: "ok" }); 601}); 602 603// *** Robots.txt - prevent crawling *** 604fastify.get("/robots.txt", async (req, reply) => { 605 reply.type("text/plain"); 606 return "User-agent: *\nDisallow: /"; 607}); 608 609// *** Module HTTP endpoint - serve modules directly (bypasses Netlify proxy) *** 610// Used by boot.mjs on localhost when the main proxy is flaky 611fastify.get("/module/*", async (req, reply) => { 612 const modulePath = req.params["*"]; 613 const moduleData = getModuleHash(modulePath); 614 615 if (moduleData) { 616 reply 617 .header("Content-Type", "application/javascript; charset=utf-8") 618 .header("Access-Control-Allow-Origin", "*") 619 .header("Cache-Control", "no-cache") 620 .send(moduleData.content); 621 } else { 622 reply.status(404).send({ error: "Module not found", path: modulePath }); 623 } 624}); 625 626// *** Build Stream - pipe terminal output to WebSocket clients *** 627// Available in both dev and production for build progress streaming 628fastify.post("/build-stream", async (req) => { 629 const line = typeof req.body === 'string' ? req.body : req.body.line || ''; 630 everyone(pack("build:log", { line, timestamp: Date.now() })); 631 return { status: "ok" }; 632}); 633 634fastify.post("/build-status", async (req) => { 635 everyone(pack("build:status", { ...req.body, timestamp: Date.now() })); 636 return { status: "ok" }; 637}); 638 639// *** FF1 Art Computer Proxy *** 640// Proxies displayPlaylist commands to FF1 via direct connection or cloud relay 641const FF1_RELAY_URL = "https://artwork-info.feral-file.workers.dev/api/cast"; 642 643// Load FF1 config from machines.json 644function getFF1Config() { 645 try { 646 const machinesPath = path.resolve(process.cwd(), "../aesthetic-computer-vault/machines.json"); 647 const machines = JSON.parse(fs.readFileSync(machinesPath, "utf8")); 648 return machines.machines?.["ff1-dvveklza"] || null; 649 } catch (e) { 650 log("⚠️ Could not load FF1 config from machines.json:", e.message); 651 return null; 652 } 653} 654 655// Execute FF1 cast via SSH through MacBook (for devcontainer) 656async function castViaSSH(ff1Config, payload) { 657 const { exec } = await import("child_process"); 658 const { promisify } = await import("util"); 659 const execAsync = promisify(exec); 660 661 const ip = ff1Config.ip; 662 const port = ff1Config.port || 1111; 663 const payloadJson = JSON.stringify(payload).replace(/'/g, "'\\''"); // Escape for shell 664 665 // SSH through MacBook to reach FF1 on local network 666 const sshCmd = `ssh -o ConnectTimeout=5 jas@host.docker.internal "curl -s --connect-timeout 5 -X POST -H 'Content-Type: application/json' http://${ip}:${port}/api/cast -d '${payloadJson}'"`; 667 668 log(`📡 FF1 cast via SSH: http://${ip}:${port}/api/cast`); 669 const { stdout, stderr } = await execAsync(sshCmd, { timeout: 15000 }); 670 671 if (stderr && !stdout) { 672 throw new Error(stderr); 673 } 674 675 try { 676 return JSON.parse(stdout); 677 } catch { 678 return { raw: stdout }; 679 } 680} 681 682fastify.post("/ff1/cast", async (req, reply) => { 683 reply.header("Access-Control-Allow-Origin", "*"); 684 reply.header("Access-Control-Allow-Methods", "POST, OPTIONS"); 685 reply.header("Access-Control-Allow-Headers", "Content-Type"); 686 687 const { topicID, apiKey, command, request, useDirect } = req.body || {}; 688 const ff1Config = getFF1Config(); 689 690 // Build the DP-1 payload 691 const payload = { 692 command: command || "displayPlaylist", 693 request: request || {} 694 }; 695 696 // Strategy 1: Try direct connection via SSH tunnel (in dev mode) 697 if (dev && ff1Config?.ip) { 698 try { 699 const result = await castViaSSH(ff1Config, payload); 700 return { success: true, method: "direct-ssh", response: result }; 701 } catch (sshErr) { 702 log(`⚠️ FF1 SSH cast failed: ${sshErr.message}`); 703 // Fall through to cloud relay 704 } 705 } 706 707 // Strategy 2: Try direct connection (if useDirect or localhost tunnel is running) 708 if (useDirect) { 709 const deviceUrl = `http://localhost:1111/api/cast`; 710 try { 711 log(`📡 FF1 direct cast to ${deviceUrl}`); 712 const directResponse = await fetch(deviceUrl, { 713 method: "POST", 714 headers: { "Content-Type": "application/json" }, 715 body: JSON.stringify(payload), 716 signal: AbortSignal.timeout(5000), // 5s timeout 717 }); 718 719 if (directResponse.ok) { 720 const result = await directResponse.json(); 721 return { success: true, method: "direct", response: result }; 722 } 723 log(`⚠️ FF1 direct cast failed: ${directResponse.status}`); 724 } catch (directErr) { 725 log(`⚠️ FF1 direct connection failed: ${directErr.message}`); 726 } 727 } 728 729 // Strategy 3: Use cloud relay with topicID 730 const relayTopicId = topicID || ff1Config?.topicId; 731 if (!relayTopicId) { 732 reply.status(400); 733 return { 734 success: false, 735 error: "No topicID provided and no FF1 config found. Get topicID from your FF1 app settings." 736 }; 737 } 738 739 const relayUrl = `${FF1_RELAY_URL}?topicID=${encodeURIComponent(relayTopicId)}`; 740 741 try { 742 log(`☁️ FF1 relay cast to ${relayUrl}`); 743 const headers = { "Content-Type": "application/json" }; 744 if (apiKey || ff1Config?.apiKey) { 745 headers["API-KEY"] = apiKey || ff1Config?.apiKey; 746 } 747 748 const relayResponse = await fetch(relayUrl, { 749 method: "POST", 750 headers, 751 body: JSON.stringify(payload), 752 signal: AbortSignal.timeout(10000), // 10s timeout 753 }); 754 755 const responseText = await relayResponse.text(); 756 let responseData; 757 try { 758 responseData = JSON.parse(responseText); 759 } catch { 760 responseData = { raw: responseText }; 761 } 762 763 if (!relayResponse.ok) { 764 // Check if relay is down (404 or Cloudflare errors) 765 if (relayResponse.status === 404 || responseText.includes("error code:")) { 766 reply.status(503); 767 return { 768 success: false, 769 error: "FF1 cloud relay is unavailable", 770 hint: "The Feral File relay service appears to be down. Use ac-ff1 tunnel for local development.", 771 details: responseData 772 }; 773 } 774 reply.status(relayResponse.status); 775 return { success: false, error: `FF1 relay error: ${relayResponse.status}`, details: responseData }; 776 } 777 778 return { success: true, method: "relay", response: responseData }; 779 } catch (relayErr) { 780 reply.status(500); 781 return { success: false, error: relayErr.message }; 782 } 783}); 784 785// FF1 CORS preflight 786fastify.options("/ff1/cast", async (req, reply) => { 787 reply.header("Access-Control-Allow-Origin", "*"); 788 reply.header("Access-Control-Allow-Methods", "POST, OPTIONS"); 789 reply.header("Access-Control-Allow-Headers", "Content-Type"); 790 return ""; 791}); 792 793// *** Chat Log Endpoint (for system logs from other services) *** 794fastify.post("/chat/log", async (req, reply) => { 795 const host = req.headers.host; 796 // Determine which chat instance based on a header or default to chat-system 797 const chatHost = req.headers["x-chat-instance"] || "chat-system.aesthetic.computer"; 798 const instance = chatManager.getInstance(chatHost); 799 800 if (!instance) { 801 reply.status(404); 802 return { status: "error", message: "Unknown chat instance" }; 803 } 804 805 const result = await chatManager.handleLog(instance, req.body, req.headers.authorization); 806 reply.status(result.status); 807 return result.body; 808}); 809 810// *** Chat Status Endpoint *** 811fastify.get("/chat/status", async (req) => { 812 return chatManager.getStatus(); 813}); 814 815const PROFILE_SECRET_CACHE_MS = 60 * 1000; 816let profileSecretCacheValue = null; 817let profileSecretCacheAt = 0; 818 819function pickProfileStreamSecret(record) { 820 if (!record || typeof record !== "object") return null; 821 const candidates = [ 822 record.secret, 823 record.token, 824 record.profileSecret, 825 record.value, 826 ]; 827 for (const raw of candidates) { 828 if (!raw) continue; 829 const value = `${raw}`.trim(); 830 if (value) return value; 831 } 832 return null; 833} 834 835function profileSecretsMatch(expected, provided) { 836 if (!expected || !provided) return false; 837 const left = Buffer.from(expected); 838 const right = Buffer.from(provided); 839 if (left.length !== right.length) return false; 840 try { 841 return crypto.timingSafeEqual(left, right); 842 } catch (_) { 843 return false; 844 } 845} 846 847async function resolveProfileStreamSecret() { 848 const now = Date.now(); 849 if (profileSecretCacheAt && now - profileSecretCacheAt < PROFILE_SECRET_CACHE_MS) { 850 return profileSecretCacheValue; 851 } 852 853 let resolved = null; 854 try { 855 if (chatManager?.db) { 856 const record = await chatManager.db 857 .collection("secrets") 858 .findOne({ _id: "profile-stream" }); 859 resolved = pickProfileStreamSecret(record); 860 } 861 } catch (err) { 862 error("👤 Could not load profile-stream secret from MongoDB:", err?.message || err); 863 } 864 865 if (!resolved) { 866 const envSecret = `${process.env.PROFILE_STREAM_SECRET || ""}`.trim(); 867 resolved = envSecret || null; 868 } 869 870 profileSecretCacheValue = resolved; 871 profileSecretCacheAt = now; 872 return profileSecretCacheValue; 873} 874 875// *** Profile Stream Event Ingest *** 876// Accepts server-to-server profile events from Netlify functions. 877fastify.post("/profile-event", async (req, reply) => { 878 try { 879 const expectedSecret = await resolveProfileStreamSecret(); 880 const providedSecret = `${req.headers["x-profile-secret"] || ""}`.trim() || null; 881 if (expectedSecret && !profileSecretsMatch(expectedSecret, providedSecret)) { 882 reply.status(401); 883 return { ok: false, error: "Unauthorized" }; 884 } 885 886 const body = req.body || {}; 887 const handle = body.handle; 888 const handleKey = normalizeProfileHandle(handle); 889 if (!handleKey) { 890 reply.status(400); 891 return { ok: false, error: "Missing or invalid handle" }; 892 } 893 894 if (body.event && typeof body.event === "object") { 895 emitProfileActivity(handleKey, body.event); 896 } 897 898 if (body.counts && typeof body.counts === "object") { 899 broadcastProfileStream(handleKey, "counts:update", { 900 handle: handleKey, 901 counts: body.counts, 902 }); 903 } 904 905 if (body.countsDelta && typeof body.countsDelta === "object") { 906 emitProfileCountDelta(handleKey, body.countsDelta); 907 } 908 909 if (body.presence && typeof body.presence === "object") { 910 broadcastProfileStream(handleKey, "presence:update", { 911 handle: handleKey, 912 reason: body.reason || "external", 913 changed: Array.isArray(body.changed) ? body.changed : [], 914 presence: body.presence, 915 }); 916 } 917 918 return { ok: true }; 919 } catch (err) { 920 error("👤 profile-event ingest failed:", err); 921 reply.status(500); 922 return { ok: false, error: err.message }; 923 } 924}); 925 926// *** Live Reload of Pieces in Development *** 927if (dev) { 928 fastify.post("/reload", async (req) => { 929 everyone(pack("reload", req.body, "pieces")); 930 return { msg: "Reload request sent!", body: req.body }; 931 }); 932 933 // Jump to a specific piece (navigate) 934 fastify.post("/jump", async (req) => { 935 const { piece } = req.body; 936 937 // Broadcast to all browser clients 938 everyone(pack("jump", { piece }, "pieces")); 939 940 // Send direct message to VSCode extension clients 941 vscodeClients.forEach(client => { 942 if (client?.readyState === WebSocket.OPEN) { 943 client.send(pack("vscode:jump", { piece }, "vscode")); 944 } 945 }); 946 947 return { 948 msg: "Jump request sent!", 949 piece, 950 vscodeConnected: vscodeClients.size > 0 951 }; 952 }); 953 954 // GET /devices - List all connected clients with metadata and names 955 fastify.get("/devices", async () => { 956 const clientList = getClientStatus(); 957 // Enhance with device names and letters 958 const enhanced = clientList.map((c, index) => ({ 959 ...c, 960 letter: getDeviceLetter(c.id), 961 deviceName: deviceNames[c.ip]?.name || null, 962 deviceGroup: deviceNames[c.ip]?.group || null, 963 })); 964 return { 965 devices: enhanced, 966 host: { name: DEV_HOST_NAME, ip: DEV_LAN_IP }, 967 timestamp: Date.now() 968 }; 969 }); 970 971 // GET /dev-info - Get dev host info for client overlay 972 fastify.get("/dev-info", async (req, reply) => { 973 // Add CORS headers for cross-origin requests from main site 974 reply.header("Access-Control-Allow-Origin", "*"); 975 reply.header("Access-Control-Allow-Methods", "GET"); 976 return { 977 host: DEV_HOST_NAME, 978 ip: DEV_LAN_IP, 979 mode: "LAN Dev", 980 timestamp: Date.now() 981 }; 982 }); 983 984 // POST /jump/:target - Targeted jump (by ID, IP, handle, or device name) 985 fastify.post("/jump/:target", async (req) => { 986 const { target } = req.params; 987 const { piece, ahistorical, alias } = req.body; 988 989 const targeted = targetClients(target); 990 if (targeted.length === 0) { 991 return { error: "No matching device", target }; 992 } 993 994 targeted.forEach(({ ws }) => { 995 ws.send(pack("jump", { piece, ahistorical, alias }, "pieces")); 996 }); 997 998 return { 999 msg: "Targeted jump sent", 1000 piece, 1001 count: targeted.length, 1002 targets: targeted.map(t => t.id) 1003 }; 1004 }); 1005 1006 // POST /reload/:target - Targeted reload 1007 fastify.post("/reload/:target", async (req) => { 1008 const { target } = req.params; 1009 const targeted = targetClients(target); 1010 1011 targeted.forEach(({ ws }) => { 1012 ws.send(pack("reload", req.body, "pieces")); 1013 }); 1014 1015 return { msg: "Targeted reload sent", count: targeted.length }; 1016 }); 1017 1018 // POST /piece-reload/:target - Targeted KidLisp reload 1019 fastify.post("/piece-reload/:target", async (req) => { 1020 const { target } = req.params; 1021 const { source, createCode, authToken } = req.body; 1022 const targeted = targetClients(target); 1023 1024 targeted.forEach(({ ws }) => { 1025 ws.send(pack("piece-reload", { source, createCode, authToken }, "kidlisp")); 1026 }); 1027 1028 return { msg: "Targeted piece-reload sent", count: targeted.length }; 1029 }); 1030 1031 // POST /device/name - Set a friendly name for a device by IP 1032 fastify.post("/device/name", async (req) => { 1033 const { ip, name, group } = req.body; 1034 if (!ip) return { error: "IP required" }; 1035 1036 const cleanIp = ip.replace('::ffff:', ''); 1037 if (name) { 1038 deviceNames[cleanIp] = { name, group: group || null, updatedAt: Date.now() }; 1039 } else { 1040 delete deviceNames[cleanIp]; 1041 } 1042 saveDeviceNames(); 1043 1044 // Notify the device of its new name 1045 const targeted = targetClients(cleanIp); 1046 targeted.forEach(({ ws }) => { 1047 ws.send(pack("dev:identity", { 1048 name, 1049 host: DEV_HOST_NAME, 1050 hostIp: DEV_LAN_IP, 1051 mode: "LAN Dev" 1052 }, "dev")); 1053 }); 1054 1055 return { 1056 msg: name ? "Device named" : "Device name cleared", 1057 ip: cleanIp, 1058 name, 1059 notified: targeted.length 1060 }; 1061 }); 1062 1063 // GET /device/names - List all device names 1064 fastify.get("/device/names", async () => { 1065 return { names: deviceNames }; 1066 }); 1067} 1068 1069// *** HTTP Server Initialization *** 1070 1071// Track UDP channels manually (geckos.io doesn't expose this) 1072const udpChannels = {}; 1073 1074// 🩰 Initialize geckos.io BEFORE server starts listening 1075// Configure for devcontainer/Docker environment: 1076// - iceServers: Use local TURN server for relay (required in Docker/devcontainer) 1077// - portRange: constrain UDP to small range that can be exposed from container 1078// - cors: allow from any origin in dev mode 1079 1080// Detect external IP for TURN server (browsers need to reach TURN from outside container) 1081// In devcontainer, we expose ports to the host, so use the host's LAN IP 1082// Priority: TURN_HOST env var > DEV_LAN_IP > localhost 1083const getExternalTurnHost = () => { 1084 // Check for explicitly set TURN host 1085 if (process.env.TURN_HOST) return process.env.TURN_HOST; 1086 // Use the DEV_LAN_IP if available (detected earlier) 1087 if (DEV_LAN_IP) return DEV_LAN_IP; 1088 // Fallback to localhost (won't work for external clients but ok for local testing) 1089 return 'localhost'; 1090}; 1091 1092const turnHost = getExternalTurnHost(); 1093console.log("🩰 TURN server host for ICE:", turnHost); 1094 1095const devIceServers = [ 1096 { urls: `stun:${turnHost}:3478` }, 1097 { 1098 urls: `turn:${turnHost}:3478`, 1099 username: 'aesthetic', 1100 credential: 'computer123' 1101 }, 1102]; 1103const prodIceServers = [ 1104 { urls: 'stun:stun.l.google.com:19302' }, 1105 // TODO: Add production TURN server 1106]; 1107 1108const io = geckos({ 1109 iceServers: dev ? devIceServers : prodIceServers, 1110 // Force relay-only mode in dev to work through container networking 1111 // Direct UDP won't work from host browser to container internal IP 1112 iceTransportPolicy: dev ? 'relay' : 'all', 1113 portRange: { 1114 min: 10000, 1115 max: 10007, 1116 }, 1117 cors: { 1118 allowAuthorization: true, 1119 origin: dev ? "*" : (req) => { 1120 const allowed = ["https://aesthetic.computer", "https://notepat.com", "https://kidlisp.com", "https://pj.kidlisp.com"]; 1121 const reqOrigin = req.headers?.origin; 1122 return allowed.includes(reqOrigin) ? reqOrigin : allowed[0]; 1123 }, 1124 }, 1125}); 1126io.addServer(server); // Hook up to the HTTP Server - must be before listen() 1127console.log("🩰 Geckos.io server attached to fastify server (UDP ports 10000-10007)"); 1128 1129const start = async () => { 1130 try { 1131 if (dev) { 1132 fastify.listen({ 1133 host: "0.0.0.0", // ip.address(), 1134 port: info.port, 1135 }); 1136 } else { 1137 fastify.listen({ host: "0.0.0.0", port: info.port }); 1138 } 1139 } catch (err) { 1140 fastify.log.error(err); 1141 process.exit(1); 1142 } 1143}; 1144 1145await start(); 1146 1147// *** Status Page Data Collection *** 1148 1149// Get unified client status - user-centric view 1150function getClientStatus() { 1151 const identityMap = new Map(); // Map by identity (handle or user or IP) 1152 1153 // Helper to get identity key for a client 1154 const getIdentityKey = (client) => { 1155 // Priority: handle > user > IP (for grouping same person) 1156 if (client.handle) return `handle:${client.handle}`; 1157 if (client.user) return `user:${client.user}`; 1158 if (client.ip) return `ip:${client.ip}`; 1159 return null; 1160 }; 1161 1162 // Process all WebSocket connections 1163 Object.keys(connections).forEach((id) => { 1164 const client = clients[id] || {}; 1165 const ws = connections[id]; 1166 const identityKey = getIdentityKey(client); 1167 1168 if (!identityKey) return; // Skip if no identity info 1169 1170 if (!identityMap.has(identityKey)) { 1171 identityMap.set(identityKey, { 1172 handle: client.handle || null, 1173 location: client.location || null, 1174 ip: client.ip || null, 1175 geo: client.geo || null, 1176 connectionIds: { websocket: [], udp: [] }, 1177 protocols: { websocket: false, udp: false }, 1178 connections: { websocket: [], udp: [] } 1179 }); 1180 } 1181 1182 const identity = identityMap.get(identityKey); 1183 1184 // Update with latest info 1185 if (client.handle && !identity.handle) identity.handle = client.handle; 1186 if (client.location) identity.location = client.location; 1187 if (client.ip && !identity.ip) identity.ip = client.ip; 1188 if (client.geo && !identity.geo) identity.geo = client.geo; 1189 1190 identity.connectionIds.websocket.push(parseInt(id)); 1191 identity.protocols.websocket = true; 1192 identity.connections.websocket.push({ 1193 id: parseInt(id), 1194 alive: ws.isAlive || false, 1195 readyState: ws.readyState, 1196 ping: ws.lastPing || null, 1197 codeChannel: findCodeChannel(parseInt(id)), 1198 worlds: getWorldMemberships(parseInt(id)) 1199 }); 1200 }); 1201 1202 // Process all UDP connections 1203 Object.keys(udpChannels).forEach((id) => { 1204 const client = clients[id] || {}; 1205 const udp = udpChannels[id]; 1206 const identityKey = getIdentityKey(client); 1207 1208 if (!identityKey) return; // Skip if no identity info 1209 1210 if (!identityMap.has(identityKey)) { 1211 identityMap.set(identityKey, { 1212 handle: client.handle || null, 1213 location: client.location || null, 1214 ip: client.ip || null, 1215 geo: client.geo || null, 1216 connectionIds: { websocket: [], udp: [] }, 1217 protocols: { websocket: false, udp: false }, 1218 connections: { websocket: [], udp: [] } 1219 }); 1220 } 1221 1222 const identity = identityMap.get(identityKey); 1223 1224 // Update with latest info 1225 if (client.handle && !identity.handle) identity.handle = client.handle; 1226 if (client.location) identity.location = client.location; 1227 if (client.ip && !identity.ip) identity.ip = client.ip; 1228 if (client.geo && !identity.geo) identity.geo = client.geo; 1229 1230 identity.connectionIds.udp.push(id); 1231 identity.protocols.udp = true; 1232 identity.connections.udp.push({ 1233 id: id, 1234 connectedAt: udp.connectedAt, 1235 state: udp.state || 'unknown' 1236 }); 1237 }); 1238 1239 // Convert to array and add summary info 1240 return Array.from(identityMap.values()).map(identity => { 1241 const wsCount = identity.connectionIds.websocket.length; 1242 const udpCount = identity.connectionIds.udp.length; 1243 const totalConnections = wsCount + udpCount; 1244 1245 return { 1246 handle: identity.handle, 1247 location: identity.location, 1248 ip: identity.ip, 1249 geo: identity.geo, 1250 protocols: identity.protocols, 1251 connectionCount: { 1252 websocket: wsCount, 1253 udp: udpCount, 1254 total: totalConnections 1255 }, 1256 // Simplified connection info - just take first of each type for display 1257 websocket: identity.connections.websocket.length > 0 ? identity.connections.websocket[0] : null, 1258 udp: identity.connections.udp.length > 0 ? identity.connections.udp[0] : null, 1259 multipleTabs: totalConnections > 1 1260 }; 1261 }); 1262} 1263 1264function getWorldMemberships(connectionId) { 1265 const worlds = []; 1266 Object.keys(worldClients).forEach(piece => { 1267 if (worldClients[piece][connectionId]) { 1268 worlds.push({ 1269 piece, 1270 handle: worldClients[piece][connectionId].handle, 1271 showing: worldClients[piece][connectionId].showing, 1272 ghost: worldClients[piece][connectionId].ghost || false, 1273 }); 1274 } 1275 }); 1276 return worlds; 1277} 1278 1279function findCodeChannel(connectionId) { 1280 for (const [channel, subscribers] of Object.entries(codeChannels)) { 1281 if (subscribers.has(connectionId)) return channel; 1282 } 1283 return null; 1284} 1285 1286function getFullStatus() { 1287 const clientList = getClientStatus(); 1288 1289 // Get chat status with recent messages 1290 const chatStatus = chatManager.getStatus(); 1291 const chatWithMessages = chatStatus.map(instance => { 1292 // Don't expose sotce chat messages — it's a paid subscriber network. 1293 const isSotce = instance.name === "chat-sotce"; 1294 const recentMessages = (!isSotce && instance.messages > 0) 1295 ? chatManager.getRecentMessages(instance.host, 5) 1296 : []; 1297 return { 1298 ...instance, 1299 recentMessages 1300 }; 1301 }); 1302 1303 // Filter old errors 1304 const cutoff = Date.now() - ERROR_RETENTION_MS; 1305 const recentErrors = errorLog.filter(e => new Date(e.timestamp).getTime() > cutoff); 1306 1307 return { 1308 timestamp: Date.now(), 1309 server: { 1310 uptime: process.uptime(), 1311 environment: dev ? "development" : "production", 1312 port: info.port, 1313 }, 1314 totals: { 1315 websocket: wss.clients.size, 1316 udp: Object.keys(udpChannels).length, 1317 unique_clients: clientList.length 1318 }, 1319 clients: clientList, 1320 chat: chatWithMessages, 1321 errors: recentErrors.slice(-20).reverse() // Most recent first 1322 }; 1323} 1324 1325 1326// *** Socket Server Initialization *** 1327// #region socket 1328let wss; 1329let connections = {}; // All active WebSocket connections. 1330const worldClients = {}; // All connected 🧒 to a space like `field`. 1331 1332let connectionId = 0; // TODO: Eventually replace with a username arrived at through 1333// a client <-> server authentication function. 1334 1335wss = new WebSocketServer({ server }); 1336log( 1337 `🤖 session.aesthetic.computer (${ 1338 dev ? "Development" : "Production" 1339 }) socket: wss://${ip.address()}:${info.port}`, 1340); 1341 1342// *** Status Page Routes (defined after wss initialization) *** 1343// Status JSON endpoint 1344fastify.get("/status", async (request, reply) => { 1345 return getFullStatus(); 1346}); 1347 1348// Status dashboard HTML at root 1349fastify.get("/", async (request, reply) => { 1350 reply.type("text/html"); 1351 return `<!DOCTYPE html> 1352<html> 1353<head> 1354 <meta charset="utf-8"> 1355 <meta name="robots" content="noindex, nofollow"> 1356 <title>session-server</title> 1357 <style> 1358 * { margin: 0; padding: 0; box-sizing: border-box; } 1359 body { 1360 font-family: monospace; 1361 background: #000; 1362 color: #0f0; 1363 padding: 1.5rem; 1364 line-height: 1.5; 1365 } 1366 .header { 1367 border-bottom: 1px solid #333; 1368 padding-bottom: 1rem; 1369 margin-bottom: 1.5rem; 1370 } 1371 .header h1 { 1372 color: #0ff; 1373 font-size: 1.2rem; 1374 } 1375 .header .status { 1376 color: #888; 1377 font-size: 0.9rem; 1378 margin-top: 0.5rem; 1379 } 1380 .grid { 1381 display: grid; 1382 grid-template-columns: 1fr 1fr; 1383 gap: 1.5rem; 1384 } 1385 @media (max-width: 900px) { 1386 .grid { grid-template-columns: 1fr; } 1387 } 1388 .section { 1389 background: #0a0a0a; 1390 border: 1px solid #222; 1391 border-radius: 4px; 1392 padding: 1rem; 1393 } 1394 .section h2 { 1395 color: #0ff; 1396 font-size: 0.95rem; 1397 margin-bottom: 0.75rem; 1398 border-bottom: 1px solid #222; 1399 padding-bottom: 0.5rem; 1400 } 1401 .client { 1402 background: #111; 1403 border-left: 3px solid #0f0; 1404 padding: 0.75rem; 1405 margin-bottom: 0.75rem; 1406 } 1407 .name { 1408 color: #0ff; 1409 font-weight: bold; 1410 } 1411 .ping { color: yellow; } 1412 .detail { 1413 color: #888; 1414 margin-top: 0.2rem; 1415 font-size: 0.85rem; 1416 } 1417 .empty { color: #555; font-style: italic; } 1418 .chat-instance { 1419 background: #111; 1420 border-left: 3px solid #f0f; 1421 padding: 0.75rem; 1422 margin-bottom: 0.75rem; 1423 } 1424 .chat-instance.offline { border-left-color: #f00; opacity: 0.6; } 1425 .chat-instance .name { color: #f0f; } 1426 .chat-msg { 1427 background: #0a0a0a; 1428 padding: 0.4rem 0.6rem; 1429 margin-top: 0.4rem; 1430 font-size: 0.8rem; 1431 border-radius: 3px; 1432 } 1433 .chat-msg .from { color: #0ff; } 1434 .chat-msg .text { color: #aaa; } 1435 .chat-msg .time { color: #555; font-size: 0.75rem; } 1436 .error-log { 1437 background: #1a0000; 1438 border-left: 3px solid #f00; 1439 padding: 0.5rem; 1440 margin-bottom: 0.5rem; 1441 font-size: 0.8rem; 1442 } 1443 .error-log .time { color: #555; } 1444 .error-log .msg { color: #f66; } 1445 .warn-log { 1446 background: #1a1a00; 1447 border-left: 3px solid #ff0; 1448 } 1449 .warn-log .msg { color: #ff6; } 1450 .no-errors { color: #0f0; font-style: italic; } 1451 .tabs { 1452 display: flex; 1453 gap: 0.5rem; 1454 margin-bottom: 1rem; 1455 } 1456 .tab { 1457 padding: 0.4rem 0.8rem; 1458 background: #111; 1459 border: 1px solid #333; 1460 color: #888; 1461 cursor: pointer; 1462 border-radius: 3px; 1463 font-family: monospace; 1464 font-size: 0.85rem; 1465 } 1466 .tab.active { 1467 background: #0f0; 1468 color: #000; 1469 border-color: #0f0; 1470 } 1471 .tab-content { display: none; } 1472 .tab-content.active { display: block; } 1473 </style> 1474</head> 1475<body> 1476 <div class="header"> 1477 <h1>🧩 session-server</h1> 1478 <div class="status"> 1479 <span id="ws-status">🔴</span> | 1480 Uptime: <span id="uptime">--</span> | 1481 Online: <span id="client-count">0</span> | 1482 Chat: <span id="chat-count">0</span> 1483 </div> 1484 </div> 1485 1486 <div class="tabs"> 1487 <button class="tab active" data-tab="overview">Overview</button> 1488 <button class="tab" data-tab="chat">💬 Chat</button> 1489 <button class="tab" data-tab="errors">⚠️ Errors</button> 1490 </div> 1491 1492 <div id="overview" class="tab-content active"> 1493 <div class="grid"> 1494 <div class="section"> 1495 <h2>🧑‍💻 Connected Clients</h2> 1496 <div id="clients"></div> 1497 </div> 1498 <div class="section"> 1499 <h2>💬 Chat Instances</h2> 1500 <div id="chat-status"></div> 1501 </div> 1502 </div> 1503 </div> 1504 1505 <div id="chat" class="tab-content"> 1506 <div class="grid"> 1507 <div class="section" id="chat-system-section"> 1508 <h2>💬 chat-system</h2> 1509 <div id="chat-system-messages"></div> 1510 </div> 1511 <div class="section" id="chat-clock-section"> 1512 <h2>🕐 chat-clock</h2> 1513 <div id="chat-clock-messages"></div> 1514 </div> 1515 1516 </div> 1517 </div> 1518 1519 <div id="errors" class="tab-content"> 1520 <div class="section"> 1521 <h2>⚠️ Recent Errors & Warnings</h2> 1522 <div id="error-log"></div> 1523 </div> 1524 </div> 1525 1526 <script> 1527 // Tab switching 1528 document.querySelectorAll('.tab').forEach(tab => { 1529 tab.addEventListener('click', () => { 1530 document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); 1531 document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); 1532 tab.classList.add('active'); 1533 document.getElementById(tab.dataset.tab).classList.add('active'); 1534 }); 1535 }); 1536 1537 const ws = new WebSocket(\`\${location.protocol === 'https:' ? 'wss:' : 'ws:'}//\${location.host}/status-stream\`); 1538 1539 ws.onopen = () => { 1540 document.getElementById('ws-status').innerHTML = '🟢'; 1541 }; 1542 1543 ws.onclose = () => { 1544 document.getElementById('ws-status').innerHTML = '🔴'; 1545 setTimeout(() => location.reload(), 2000); 1546 }; 1547 1548 ws.onmessage = (event) => { 1549 const data = JSON.parse(event.data); 1550 if (data.type === 'status') update(data.data); 1551 }; 1552 1553 function formatTime(dateStr) { 1554 if (!dateStr) return ''; 1555 const d = new Date(dateStr); 1556 return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); 1557 } 1558 1559 function escapeHtml(str) { 1560 if (!str) return ''; 1561 return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 1562 } 1563 1564 function update(s) { 1565 const hrs = Math.floor(s.server.uptime / 3600); 1566 const min = Math.floor((s.server.uptime % 3600) / 60); 1567 document.getElementById('uptime').textContent = \`\${hrs}h \${min}m\`; 1568 document.getElementById('client-count').textContent = s.totals.unique_clients; 1569 1570 // Chat instance count 1571 const totalChatters = s.chat ? s.chat.reduce((sum, c) => sum + c.connections, 0) : 0; 1572 document.getElementById('chat-count').textContent = totalChatters; 1573 1574 // Clients section 1575 const clientsHtml = s.clients.length === 0 1576 ? '<div class="empty">Nobody online</div>' 1577 : s.clients.map(c => { 1578 let out = '<div class="client">'; 1579 out += '<div class="name">'; 1580 out += escapeHtml(c.handle) || '(anonymous)'; 1581 if (c.multipleTabs && c.connectionCount.total > 1) out += \`\${c.connectionCount.total})\`; 1582 if (c.websocket?.ping) out += \` <span class="ping">(\${c.websocket.ping}ms)</span>\`; 1583 out += '</div>'; 1584 if (c.location && c.location !== '*keep-alive*') out += \`<div class="detail">📍 \${escapeHtml(c.location)}</div>\`; 1585 if (c.geo) { 1586 let geo = '🗺️ '; 1587 if (c.geo.city) geo += c.geo.city + ', '; 1588 if (c.geo.region) geo += c.geo.region + ', '; 1589 geo += c.geo.country; 1590 out += \`<div class="detail">\${geo}</div>\`; 1591 } else if (c.ip) { 1592 out += \`<div class="detail">🌐 \${c.ip}</div>\`; 1593 } 1594 if (c.websocket?.worlds?.length > 0) { 1595 const w = c.websocket.worlds[0]; 1596 out += \`<div class="detail">🌍 \${escapeHtml(w.piece)}\`; 1597 if (w.showing) out += \` (viewing \${escapeHtml(w.showing)})\`; 1598 if (w.ghost) out += ' 👻'; 1599 out += '</div>'; 1600 } 1601 const p = []; 1602 if (c.protocols.websocket) p.push(c.connectionCount.websocket > 1 ? \`ws×\${c.connectionCount.websocket}\` : 'ws'); 1603 if (c.protocols.udp) p.push(c.connectionCount.udp > 1 ? \`udp×\${c.connectionCount.udp}\` : 'udp'); 1604 if (p.length) out += \`<div class="detail" style="opacity:0.5">\${p.join(' + ')}</div>\`; 1605 out += '</div>'; 1606 return out; 1607 }).join(''); 1608 document.getElementById('clients').innerHTML = clientsHtml; 1609 1610 // Chat status section (overview) 1611 if (s.chat) { 1612 const chatHtml = s.chat.map(c => { 1613 const isOnline = c.messages >= 0; 1614 return \`<div class="chat-instance \${isOnline ? '' : 'offline'}"> 1615 <div class="name">\${escapeHtml(c.name)} \${isOnline ? '🟢' : '🔴'}</div> 1616 <div class="detail">🧑‍🤝‍🧑 \${c.connections} connected</div> 1617 <div class="detail">💾 \${c.messages} messages loaded</div> 1618 </div>\`; 1619 }).join(''); 1620 document.getElementById('chat-status').innerHTML = chatHtml; 1621 } else { 1622 document.getElementById('chat-status').innerHTML = '<div class="empty">Chat not initialized</div>'; 1623 } 1624 1625 // Chat messages (detailed view) 1626 if (s.chat) { 1627 s.chat.forEach(c => { 1628 const name = c.name.replace('chat-', ''); 1629 const el = document.getElementById(\`chat-\${name}-messages\`) || document.getElementById(\`chat-\${c.name}-messages\`); 1630 if (el && c.recentMessages) { 1631 const msgsHtml = c.recentMessages.length === 0 1632 ? '<div class="empty">No recent messages</div>' 1633 : c.recentMessages.map(m => \`<div class="chat-msg"> 1634 <span class="from">\${escapeHtml(m.from)}</span> 1635 <span class="text">\${escapeHtml(m.text)}</span> 1636 <span class="time">\${formatTime(m.when)}</span> 1637 </div>\`).join(''); 1638 el.innerHTML = msgsHtml; 1639 } 1640 }); 1641 } 1642 1643 // Error log 1644 if (s.errors && s.errors.length > 0) { 1645 const errHtml = s.errors.map(e => \`<div class="\${e.level === 'error' ? 'error-log' : 'warn-log error-log'}"> 1646 <span class="time">[\${formatTime(e.timestamp)}]</span> 1647 <span class="msg">\${escapeHtml(e.message)}</span> 1648 </div>\`).join(''); 1649 document.getElementById('error-log').innerHTML = errHtml; 1650 } else { 1651 document.getElementById('error-log').innerHTML = '<div class="no-errors">✅ No errors in the last hour</div>'; 1652 } 1653 } 1654 </script> 1655</body> 1656</html>`; 1657}); 1658 1659// Pack messages into a simple object protocol of `{type, content}`. 1660function pack(type, content, id) { 1661 return JSON.stringify({ type, content, id }); 1662} 1663 1664// Enable ping-pong behavior to keep connections persistently tracked. 1665// (In the future could just tie connections to logged in users or 1666// persistent tokens to keep persistence.) 1667const interval = setInterval(function ping() { 1668 wss.clients.forEach((client) => { 1669 if (client.isAlive === false) { 1670 return client.terminate(); 1671 } 1672 client.isAlive = false; 1673 client.pingStart = Date.now(); // Start ping timer 1674 client.ping(); 1675 }); 1676}, 15000); // 15 second pings from server before termination. 1677 1678wss.on("close", function close() { 1679 clearInterval(interval); 1680 connections = {}; 1681}); 1682 1683// Construct the server. 1684wss.on("connection", async (ws, req) => { 1685 const connectionInfo = { 1686 url: req.url, 1687 host: req.headers.host, 1688 origin: req.headers.origin, 1689 userAgent: req.headers['user-agent'], 1690 remoteAddress: req.socket.remoteAddress, 1691 }; 1692 log('🔌 WebSocket connection received:', JSON.stringify(connectionInfo, null, 2)); 1693 log('🔌 Total wss.clients.size:', wss.clients.size); 1694 log('🔌 Current connections count:', Object.keys(connections).length); 1695 1696 // Route status dashboard WebSocket connections separately 1697 if (req.url === '/status-stream') { 1698 log('📊 Status dashboard viewer connected from:', req.socket.remoteAddress); 1699 statusClients.add(ws); 1700 1701 // Mark as dashboard viewer (don't add to game clients) 1702 ws.isDashboardViewer = true; 1703 1704 // Send initial state 1705 ws.send(JSON.stringify({ 1706 type: 'status', 1707 data: getFullStatus(), 1708 })); 1709 1710 ws.on('close', () => { 1711 log('📊 Status dashboard viewer disconnected'); 1712 statusClients.delete(ws); 1713 }); 1714 1715 ws.on('error', (err) => { 1716 error('📊 Status dashboard error:', err); 1717 statusClients.delete(ws); 1718 }); 1719 1720 return; // Don't process as a game client 1721 } 1722 1723 // Route targeted profile stream connections 1724 if (req.url?.startsWith('/profile-stream')) { 1725 let requestedHandle = null; 1726 try { 1727 const parsedUrl = new URL(req.url, 'http://localhost'); 1728 requestedHandle = parsedUrl.searchParams.get('handle'); 1729 } catch (err) { 1730 error('👤 Invalid profile-stream URL:', err); 1731 } 1732 1733 const key = addProfileStreamClient(ws, requestedHandle); 1734 if (!key) { 1735 ws.send( 1736 JSON.stringify({ 1737 type: 'profile:error', 1738 data: { message: 'Missing or invalid handle query param.' }, 1739 }), 1740 ); 1741 try { ws.close(); } catch (_) {} 1742 return; 1743 } 1744 1745 log('👤 Profile stream viewer connected for:', key, 'from:', req.socket.remoteAddress); 1746 1747 ws.on('close', () => { 1748 removeProfileStreamClient(ws); 1749 log('👤 Profile stream viewer disconnected for:', key); 1750 }); 1751 1752 ws.on('error', (err) => { 1753 error('👤 Profile stream error:', err); 1754 removeProfileStreamClient(ws); 1755 }); 1756 1757 return; // Don't process as a game client 1758 } 1759 1760 // Route chat connections to ChatManager based on host 1761 const host = req.headers.host; 1762 if (chatManager.isChatHost(host)) { 1763 log('💬 Chat client connection from:', host); 1764 chatManager.handleConnection(ws, req); 1765 return; // Don't process as a game client 1766 } 1767 1768 // Route AC Machines connections — device monitoring & remote commands 1769 if (req.url.startsWith('/machines')) { 1770 const urlParams = new URL(req.url, 'http://localhost').searchParams; 1771 const role = urlParams.get('role') || 'device'; 1772 const token = urlParams.get('token') || ''; 1773 const machineId = urlParams.get('machineId') || ''; 1774 1775 if (role === 'viewer') { 1776 // Browser dashboard viewer — verify AC auth token via Auth0 1777 const authUser = await verifyACToken(token); 1778 if (!authUser?.sub) { 1779 ws.close(4001, 'Unauthorized'); 1780 return; 1781 } 1782 const userSub = authUser.sub; 1783 const userHandle = authUser.nickname || authUser.name || null; 1784 1785 log(`Machines viewer connected: ${userHandle || userSub}`); 1786 1787 if (!machinesViewers.has(userSub)) machinesViewers.set(userSub, new Set()); 1788 machinesViewers.get(userSub).add(ws); 1789 1790 // Send initial state: all online machines for this user 1791 const userMachines = []; 1792 for (const [mid, device] of machinesDevices) { 1793 if (device.user === userSub) { 1794 userMachines.push({ 1795 machineId: mid, 1796 ...device.info, 1797 status: "online", 1798 lastHeartbeat: device.lastHeartbeat, 1799 }); 1800 } 1801 } 1802 ws.send(JSON.stringify({ type: "machines-state", machines: userMachines })); 1803 1804 // Handle viewer → device commands 1805 ws.on('message', (data) => { 1806 try { 1807 const msg = JSON.parse(data.toString()); 1808 if (msg.type === "command" && msg.machineId) { 1809 const device = machinesDevices.get(msg.machineId); 1810 if (device && device.user === userSub && device.ws.readyState === WebSocket.OPEN) { 1811 const commandId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); 1812 device.ws.send(JSON.stringify({ 1813 type: "command", 1814 command: msg.cmd, 1815 commandId, 1816 target: msg.args?.target || msg.args?.piece || undefined, 1817 })); 1818 log(`Command '${msg.cmd}' → ${msg.machineId} (${commandId})`); 1819 } 1820 } 1821 // Swank eval: forward CL expression to device for evaluation 1822 if (msg.type === "swank:eval" && msg.machineId && msg.expr) { 1823 const device = machinesDevices.get(msg.machineId); 1824 if (device && device.user === userSub && device.ws.readyState === WebSocket.OPEN) { 1825 const evalId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); 1826 device.ws.send(JSON.stringify({ 1827 type: "swank:eval", 1828 expr: msg.expr, 1829 evalId, 1830 })); 1831 log(`🔮 Swank eval → ${msg.machineId}: ${msg.expr.slice(0, 60)}`); 1832 } 1833 } 1834 } catch (e) { 1835 error('🖥️ Machines viewer message error:', e); 1836 } 1837 }); 1838 1839 ws.on('close', () => { 1840 log(`🖥️ Machines viewer disconnected: ${userHandle || userSub}`); 1841 const viewers = machinesViewers.get(userSub); 1842 if (viewers) { 1843 viewers.delete(ws); 1844 if (viewers.size === 0) machinesViewers.delete(userSub); 1845 } 1846 }); 1847 1848 ws.on('error', (err) => { 1849 error('🖥️ Machines viewer error:', err); 1850 const viewers = machinesViewers.get(userSub); 1851 if (viewers) { 1852 viewers.delete(ws); 1853 if (viewers.size === 0) machinesViewers.delete(userSub); 1854 } 1855 }); 1856 1857 } else { 1858 // Device connection 1859 const tokenPayload = await verifyMachineToken(token); 1860 const userSub = tokenPayload?.sub || null; 1861 const userHandle = tokenPayload?.handle || null; 1862 const linked = !!tokenPayload; 1863 1864 log(`📡 Machines device connected: ${machineId} (${linked ? userHandle : 'unlinked'})`); 1865 1866 machinesDevices.set(machineId, { 1867 ws, user: userSub, handle: userHandle, machineId, linked, 1868 info: {}, lastHeartbeat: Date.now(), 1869 }); 1870 1871 if (userSub) { 1872 broadcastToMachineViewers(userSub, { type: "device-connected", machineId, linked }); 1873 } 1874 1875 ws.on('message', async (data) => { 1876 try { 1877 const msg = JSON.parse(data.toString()); 1878 const device = machinesDevices.get(machineId); 1879 if (!device) return; 1880 1881 switch (msg.type) { 1882 case "register": 1883 device.info = { 1884 version: msg.version, buildName: msg.buildName, 1885 gitHash: msg.gitHash, buildTs: msg.buildTs, 1886 hw: msg.hw, ip: msg.ip, wifiSSID: msg.wifiSSID, 1887 hostname: msg.hostname, label: msg.label, 1888 currentPiece: msg.currentPiece || "notepat", 1889 }; 1890 device.lastHeartbeat = Date.now(); 1891 try { await upsertMachine(userSub, machineId, device.info); } catch (e) { error("📡 upsert:", e.message); } 1892 if (userSub) broadcastToMachineViewers(userSub, { type: "machine-registered", machineId, ...device.info, status: "online" }); 1893 break; 1894 1895 case "heartbeat": 1896 device.lastHeartbeat = Date.now(); 1897 device.info.uptime = msg.uptime; 1898 device.info.currentPiece = msg.currentPiece || device.info.currentPiece; 1899 device.info.battery = msg.battery; 1900 device.info.charging = msg.charging; 1901 device.info.fps = msg.fps; 1902 try { await updateMachineHeartbeat(userSub, machineId, msg.uptime, device.info.currentPiece); } catch (e) { error("📡 heartbeat:", e.message); } 1903 if (userSub) broadcastToMachineViewers(userSub, { 1904 type: "heartbeat", machineId, uptime: msg.uptime, 1905 currentPiece: device.info.currentPiece, 1906 battery: msg.battery, charging: msg.charging, fps: msg.fps, 1907 timestamp: Date.now(), 1908 }); 1909 break; 1910 1911 case "log": 1912 try { await insertMachineLog(userSub, machineId, msg); } catch (e) { error("📡 log insert:", e.message); } 1913 if (userSub) { 1914 const logMessage = msg.message || (typeof msg.data === "string" ? msg.data : JSON.stringify(msg.data)); 1915 broadcastToMachineViewers(userSub, { 1916 type: "log", machineId, level: msg.logType === "crash" ? "error" : (msg.level || "info"), 1917 message: logMessage, logType: msg.logType || "log", 1918 data: msg.data || null, 1919 when: msg.when || new Date().toISOString(), 1920 }); 1921 } 1922 break; 1923 1924 case "command-ack": 1925 case "command-response": 1926 if (userSub) broadcastToMachineViewers(userSub, { type: msg.type, machineId, commandId: msg.commandId, command: msg.command, data: msg.data }); 1927 break; 1928 1929 case "swank:result": 1930 // Forward Swank eval result from device to viewer 1931 if (userSub) broadcastToMachineViewers(userSub, { 1932 type: "swank:result", machineId, 1933 evalId: msg.evalId, ok: msg.ok, result: msg.result, 1934 }); 1935 break; 1936 } 1937 } catch (e) { 1938 error('📡 Machines device message error:', e); 1939 } 1940 }); 1941 1942 ws.on('close', async () => { 1943 log(`📡 Machines device disconnected: ${machineId}`); 1944 machinesDevices.delete(machineId); 1945 if (userSub) { 1946 broadcastToMachineViewers(userSub, { type: "status-change", machineId, status: "offline" }); 1947 try { await setMachineOffline(userSub, machineId); } catch (e) { error("📡 offline:", e.message); } 1948 } 1949 }); 1950 1951 ws.on('error', (err) => { 1952 error(`📡 Machines device error (${machineId}):`, err); 1953 machinesDevices.delete(machineId); 1954 }); 1955 } 1956 1957 return; // Don't process as a game client 1958 } 1959 1960 // Route socklogs connections - devices sending logs and viewers subscribing 1961 if (req.url.startsWith('/socklogs')) { 1962 const urlParams = new URL(req.url, 'http://localhost').searchParams; 1963 const role = urlParams.get('role') || 'device'; // 'device' or 'viewer' 1964 const deviceId = urlParams.get('deviceId') || `device-${Date.now()}`; 1965 1966 if (role === 'viewer') { 1967 // Viewer wants to see logs from devices 1968 log('👁️ SockLogs viewer connected'); 1969 socklogsViewers.add(ws); 1970 1971 // Send current status 1972 ws.send(JSON.stringify({ 1973 type: 'status', 1974 ...socklogsStatus() 1975 })); 1976 1977 ws.on('close', () => { 1978 log('👁️ SockLogs viewer disconnected'); 1979 socklogsViewers.delete(ws); 1980 }); 1981 1982 ws.on('error', (err) => { 1983 error('👁️ SockLogs viewer error:', err); 1984 socklogsViewers.delete(ws); 1985 }); 1986 } else { 1987 // Device sending logs 1988 log(`📱 SockLogs device connected: ${deviceId}`); 1989 socklogsDevices.set(deviceId, { 1990 ws, 1991 logCount: 0, 1992 lastLog: null, 1993 connectedAt: Date.now() 1994 }); 1995 1996 // Notify viewers of new device 1997 for (const viewer of socklogsViewers) { 1998 if (viewer.readyState === WebSocket.OPEN) { 1999 viewer.send(JSON.stringify({ 2000 type: 'device-connected', 2001 deviceId, 2002 status: socklogsStatus() 2003 })); 2004 } 2005 } 2006 2007 ws.on('message', (data) => { 2008 try { 2009 const msg = JSON.parse(data.toString()); 2010 if (msg.type === 'log') { 2011 const device = socklogsDevices.get(deviceId); 2012 if (device) { 2013 device.logCount++; 2014 device.lastLog = Date.now(); 2015 } 2016 socklogsBroadcast(deviceId, msg); 2017 } 2018 } catch (e) { 2019 error('📱 SockLogs parse error:', e); 2020 } 2021 }); 2022 2023 ws.on('close', () => { 2024 log(`📱 SockLogs device disconnected: ${deviceId}`); 2025 socklogsDevices.delete(deviceId); 2026 2027 // Notify viewers 2028 for (const viewer of socklogsViewers) { 2029 if (viewer.readyState === WebSocket.OPEN) { 2030 viewer.send(JSON.stringify({ 2031 type: 'device-disconnected', 2032 deviceId, 2033 status: socklogsStatus() 2034 })); 2035 } 2036 } 2037 }); 2038 2039 ws.on('error', (err) => { 2040 error(`📱 SockLogs device error (${deviceId}):`, err); 2041 socklogsDevices.delete(deviceId); 2042 }); 2043 } 2044 2045 return; // Don't process as a game client 2046 } 2047 2048 log('🎮 Game client connection detected, adding to connections'); 2049 2050 // Regular game client connection handling below 2051 const ip = req.socket.remoteAddress || "localhost"; // beautify ip 2052 ws.isAlive = true; // For checking persistence between ping-pong messages. 2053 ws.pingStart = null; // Track ping timing 2054 ws.lastPing = null; // Store last measured ping 2055 2056 ws.on("pong", () => { 2057 ws.isAlive = true; 2058 if (ws.pingStart) { 2059 ws.lastPing = Date.now() - ws.pingStart; 2060 ws.pingStart = null; 2061 } 2062 }); // Receive a pong and stay alive! 2063 2064 // Assign the conection a unique id. 2065 connections[connectionId] = ws; 2066 const id = connectionId; 2067 let codeChannel; // Used to subscribe to incoming piece code. 2068 2069 // Initialize client record with IP and geolocation 2070 if (!clients[id]) clients[id] = {}; 2071 clients[id].websocket = true; 2072 2073 // Clean IP and get geolocation 2074 const cleanIp = ip.replace('::ffff:', ''); 2075 clients[id].ip = cleanIp; 2076 2077 const geo = geoip.lookup(cleanIp); 2078 if (geo) { 2079 clients[id].geo = { 2080 country: geo.country, 2081 region: geo.region, 2082 city: geo.city, 2083 timezone: geo.timezone, 2084 ll: geo.ll // [latitude, longitude] 2085 }; 2086 log(`🌍 Geolocation for ${cleanIp}:`, geo.country, geo.region, geo.city); 2087 } else { 2088 log(`🌍 No geolocation data for ${cleanIp}`); 2089 } 2090 2091 log("🧏 Someone joined:", `${id}:${ip}`, "Online:", wss.clients.size, "🫂"); 2092 log("🎮 Added to connections. Total game clients:", Object.keys(connections).length); 2093 2094 const content = { id, playerCount: wss.clients.size }; 2095 2096 // Send a message to all other clients except this one. 2097 function others(string) { 2098 wss.clients.forEach((c) => { 2099 if (c !== ws && c?.readyState === WebSocket.OPEN) c.send(string); 2100 }); 2101 } 2102 2103 // Send a self-connection message back to the client. 2104 ws.send( 2105 pack( 2106 "connected", 2107 JSON.stringify({ ip, playerCount: content.playerCount }), 2108 id, 2109 ), 2110 ); 2111 2112 // In dev mode, send device identity info for LAN overlay 2113 if (dev) { 2114 const deviceName = deviceNames[cleanIp]?.name || null; 2115 const deviceLetter = getDeviceLetter(id); 2116 const identityPayload = { 2117 name: deviceName, 2118 letter: deviceLetter, 2119 host: DEV_HOST_NAME, 2120 hostIp: DEV_LAN_IP, 2121 mode: "LAN Dev", 2122 connectionId: id, 2123 }; 2124 console.log(`📱 Sending dev:identity to ${cleanIp}:`, identityPayload); 2125 ws.send(pack("dev:identity", identityPayload, "dev")); 2126 } 2127 2128 // Send a join message to everyone else. 2129 others( 2130 pack( 2131 "joined", 2132 JSON.stringify({ 2133 text: `${connectionId} has joined. Connections open: ${content.playerCount}`, 2134 }), 2135 id, 2136 ), 2137 ); 2138 2139 connectionId += 1; 2140 2141 // Relay all incoming messages from this client to everyone else. 2142 ws.on("message", (data) => { 2143 // Parse incoming message and attach client identifier. 2144 let msg; 2145 try { 2146 msg = JSON.parse(data.toString()); 2147 } catch (error) { 2148 console.error("📚 Failed to parse JSON:", error); 2149 return; 2150 } 2151 2152 // 📦 Module streaming - handle module requests before other processing 2153 if (msg.type === "module:request") { 2154 const modulePath = msg.path; 2155 const withDeps = msg.withDeps === true; // Request all dependencies too 2156 const knownHashes = msg.knownHashes || {}; // Client's cached hashes 2157 2158 if (withDeps) { 2159 // Recursively gather all dependencies 2160 const modules = {}; 2161 let skippedCount = 0; 2162 2163 const gatherDeps = (p, fromPath = null) => { 2164 if (modules[p] || modules[p] === null) return; // Already gathered (or marked as cached) 2165 const data = getModuleHash(p); 2166 if (!data) { 2167 // Only warn for top-level not found, not for deps (which might be optional) 2168 if (!fromPath) log(`📦 Module not found: ${p}`); 2169 return; 2170 } 2171 2172 // Check if client already has this hash cached 2173 if (knownHashes[p] === data.hash) { 2174 modules[p] = null; // Mark as "client has it" - don't send content 2175 skippedCount++; 2176 } else { 2177 modules[p] = { hash: data.hash, content: data.content }; 2178 } 2179 2180 // Debug: show when gathering specific important modules 2181 if (p.includes('headers') || p.includes('kidlisp')) { 2182 log(`📦 Gathering ${p} (from ${fromPath || 'top'})${knownHashes[p] === data.hash ? ' [cached]' : ''}`); 2183 } 2184 2185 // Parse static imports from content - match ES module import/export from statements 2186 // This regex only matches valid relative imports ending in .mjs or .js 2187 // Skip commented lines by checking each line doesn't start with // 2188 const staticImportRegex = /^(?!\s*\/\/).*?(?:import|export)\s+(?:[^;]*?\s+from\s+)?["'](\.{1,2}\/[^"'\s]+\.m?js)["']/gm; 2189 let match; 2190 while ((match = staticImportRegex.exec(data.content)) !== null) { 2191 const importPath = match[1]; 2192 // Skip invalid paths 2193 if (importPath.includes('...') || importPath.length > 200) continue; 2194 2195 // Resolve relative path 2196 const dir = path.dirname(p); 2197 const resolved = path.normalize(path.join(dir, importPath)); 2198 log(`📦 Found dep: ${p} -> ${importPath} (resolved: ${resolved})`); 2199 gatherDeps(resolved, p); 2200 } 2201 2202 // Parse dynamic imports - import("./path") or import('./path') or import(`./path`) 2203 // Skip commented lines 2204 const dynamicImportRegex = /^(?!\s*\/\/).*?import\s*\(\s*["'`](\.{1,2}\/[^"'`\s]+\.m?js)["'`]\s*\)/gm; 2205 while ((match = dynamicImportRegex.exec(data.content)) !== null) { 2206 const importPath = match[1]; 2207 // Skip invalid paths 2208 if (importPath.includes('...') || importPath.length > 200) continue; 2209 2210 // Resolve relative path 2211 const dir = path.dirname(p); 2212 const resolved = path.normalize(path.join(dir, importPath)); 2213 gatherDeps(resolved, p); 2214 } 2215 }; 2216 2217 gatherDeps(modulePath); 2218 2219 // Filter out null entries (modules client already has) and count 2220 const modulesToSend = {}; 2221 const cachedPaths = []; 2222 for (const [p, data] of Object.entries(modules)) { 2223 if (data === null) { 2224 cachedPaths.push(p); 2225 } else { 2226 modulesToSend[p] = data; 2227 } 2228 } 2229 2230 const totalModules = Object.keys(modules).length; 2231 const sentModules = Object.keys(modulesToSend).length; 2232 2233 if (totalModules > 0) { 2234 // Log bundle stats 2235 if (skippedCount > 0) { 2236 log(`📦 Bundle for ${modulePath}: ${sentModules}/${totalModules} sent (${skippedCount} cached)`); 2237 } else { 2238 log(`📦 Bundle for ${modulePath}: ${sentModules} modules`); 2239 } 2240 2241 ws.send(JSON.stringify({ 2242 type: "module:bundle", 2243 entry: modulePath, 2244 modules: modulesToSend, 2245 cached: cachedPaths // Tell client which paths to use from cache 2246 })); 2247 } else { 2248 ws.send(JSON.stringify({ 2249 type: "module:error", 2250 path: modulePath, 2251 error: "Module not found" 2252 })); 2253 } 2254 } else { 2255 // Single module request (original behavior) 2256 const moduleData = getModuleHash(modulePath); 2257 2258 if (moduleData) { 2259 ws.send(JSON.stringify({ 2260 type: "module:response", 2261 path: modulePath, 2262 hash: moduleData.hash, 2263 content: moduleData.content 2264 })); 2265 log(`📦 Module sent: ${modulePath} (${moduleData.content.length} bytes)`); 2266 } else { 2267 ws.send(JSON.stringify({ 2268 type: "module:error", 2269 path: modulePath, 2270 error: "Module not found" 2271 })); 2272 log(`📦 Module not found: ${modulePath}`); 2273 } 2274 } 2275 return; 2276 } 2277 2278 if (msg.type === "module:check") { 2279 const modulePath = msg.path; 2280 const clientHash = msg.hash; 2281 const moduleData = getModuleHash(modulePath); 2282 2283 if (moduleData) { 2284 ws.send(JSON.stringify({ 2285 type: "module:status", 2286 path: modulePath, 2287 changed: moduleData.hash !== clientHash, 2288 hash: moduleData.hash 2289 })); 2290 } else { 2291 ws.send(JSON.stringify({ 2292 type: "module:status", 2293 path: modulePath, 2294 changed: true, 2295 hash: null, 2296 error: "Module not found" 2297 })); 2298 } 2299 return; 2300 } 2301 2302 if (msg.type === "module:list") { 2303 // Return list of available modules (for prefetching) 2304 const modules = [ 2305 "lib/disk.mjs", 2306 "lib/graph.mjs", 2307 "lib/num.mjs", 2308 "lib/geo.mjs", 2309 "lib/parse.mjs", 2310 "lib/help.mjs", 2311 "lib/text.mjs", 2312 "bios.mjs" 2313 ]; 2314 const moduleInfo = modules.map(p => { 2315 const data = getModuleHash(p); 2316 return data ? { path: p, hash: data.hash, size: data.content.length } : null; 2317 }).filter(Boolean); 2318 2319 ws.send(JSON.stringify({ 2320 type: "module:list", 2321 modules: moduleInfo 2322 })); 2323 return; 2324 } 2325 2326 // 🎹 DAW Channel - M4L device ↔ IDE communication 2327 if (msg.type === "daw:join") { 2328 // Device (kidlisp.com/device) joining to receive code 2329 dawDevices.add(id); 2330 log(`🎹 DAW device joined: ${id} (total: ${dawDevices.size})`); 2331 ws.send(JSON.stringify({ type: "daw:joined", id })); 2332 return; 2333 } 2334 2335 if (msg.type === "daw:code") { 2336 // IDE sending code to all connected devices 2337 log(`🎹 DAW code broadcast from ${id} to ${dawDevices.size} devices`); 2338 const codeMsg = JSON.stringify({ 2339 type: "daw:code", 2340 content: msg.content, 2341 from: id 2342 }); 2343 2344 // Broadcast to all DAW devices 2345 for (const deviceId of dawDevices) { 2346 const deviceWs = connections[deviceId]; 2347 if (deviceWs && deviceWs.readyState === WebSocket.OPEN) { 2348 deviceWs.send(codeMsg); 2349 log(`🎹 Sent code to device ${deviceId}`); 2350 } 2351 } 2352 return; 2353 } 2354 2355 if (msg.type === "notepat:midi:sources") { 2356 sendNotepatMidiSources(ws); 2357 return; 2358 } 2359 2360 if (msg.type === "notepat:midi:subscribe") { 2361 const filter = msg.content || {}; 2362 addNotepatMidiSubscriber(id, ws, filter); 2363 return; 2364 } 2365 2366 if (msg.type === "notepat:midi:unsubscribe") { 2367 removeNotepatMidiSubscriber(id); 2368 if (ws.readyState === WebSocket.OPEN) { 2369 ws.send(pack("notepat:midi:unsubscribed", true, "midi-relay")); 2370 } 2371 return; 2372 } 2373 2374 msg.id = id; // TODO: When sending a server generated message, use a special id. 2375 2376 // Extract user identity and handle from ANY message that contains it 2377 if (msg.content?.user?.sub) { 2378 if (!clients[id]) clients[id] = { websocket: true }; 2379 2380 const userSub = msg.content.user.sub; 2381 const userChanged = !clients[id].user || clients[id].user !== userSub; 2382 2383 if (userChanged) { 2384 clients[id].user = userSub; 2385 log("🔑 User identity from", msg.type + ":", userSub.substring(0, 20) + "...", "conn:", id); 2386 } 2387 2388 // Extract handle from message if present (e.g., location:broadcast includes it) 2389 if (msg.content.handle && (!clients[id].handle || clients[id].handle !== msg.content.handle)) { 2390 clients[id].handle = msg.content.handle; 2391 log("✅ Handle from message:", msg.content.handle, "conn:", id); 2392 emitProfilePresence(msg.content.handle, "identify", ["handle"]); 2393 } 2394 } 2395 2396 if (msg.type === "scream") { 2397 // Alert all connected users via redis pub/sub to the scream. 2398 log("😱 About to scream..."); 2399 const out = filter(msg.content); 2400 pub 2401 .publish("scream", out) 2402 .then((result) => { 2403 log("😱 Scream succesfully published:", result); 2404 2405 let piece = ""; 2406 if (out.indexOf("pond") > -1) piece = "pond"; 2407 else if (out.indexOf("field") > -1) piece = "field"; 2408 2409 //if (!dev) { 2410 getMessaging() 2411 .send({ 2412 notification: { 2413 title: "😱 Scream", 2414 body: out, //, 2415 }, 2416 // android: { 2417 // notification: { 2418 // imageUrl: "https://aesthetic.computer/api/logo.png", 2419 // }, 2420 apns: { 2421 payload: { 2422 aps: { 2423 "mutable-content": 1, 2424 "interruption-level": "time-sensitive", // Marks as time-sensitive 2425 priority: 10, // Highest priority 2426 "content-available": 1, // Tells iOS to wake the app 2427 }, 2428 }, 2429 headers: { 2430 "apns-priority": "10", // Immediate delivery priority 2431 "apns-push-type": "alert", // Explicit push type 2432 "apns-expiration": "0", // Message won't be stored by APNs 2433 }, 2434 fcm_options: { 2435 image: "https://aesthetic.computer/api/logo.png", 2436 }, 2437 }, 2438 webpush: { 2439 headers: { 2440 image: "https://aesthetic.computer/api/logo.png", 2441 }, 2442 }, 2443 topic: "scream", 2444 data: { piece }, 2445 }) 2446 .then((response) => { 2447 log("☎️ Successfully sent notification:", response); 2448 }) 2449 .catch((error) => { 2450 log("📵 Error sending notification:", error); 2451 }); 2452 //} 2453 }) 2454 .catch((error) => { 2455 log("🙅‍♀️ Error publishing scream:", error); 2456 }); 2457 // Send a notification to all devices subscribed to the `scream` topic. 2458 } else if (msg.type === "code-channel:sub") { 2459 // Filter code-channel updates based on this user. 2460 codeChannel = msg.content; 2461 if (!codeChannels[codeChannel]) codeChannels[codeChannel] = new Set(); 2462 codeChannels[codeChannel].add(id); 2463 2464 // Send current channel state to late joiners 2465 if (codeChannelState[codeChannel]) { 2466 // Note: codeChannelState stores the original msg.content object, 2467 // pack() will JSON.stringify it, so don't double-stringify here 2468 const stateMsg = pack("code", codeChannelState[codeChannel], id); 2469 send(stateMsg); 2470 log(`📥 Sent current state to late joiner on channel ${codeChannel}`); 2471 } 2472 } else if (msg.type === "code-channel:info") { 2473 // Return viewer count for a code channel 2474 const ch = msg.content; 2475 const count = codeChannels[ch]?.size || 0; 2476 send(pack("code-channel:info", { channel: ch, viewers: count }, id)); 2477 } else if (msg.type === "slide" && msg.content?.codeChannel) { 2478 // Handle slide broadcast (low-latency value updates, no state storage) 2479 const targetChannel = msg.content.codeChannel; 2480 2481 // Don't store slide updates as state (they're transient) 2482 // Just broadcast immediately for low latency 2483 if (codeChannels[targetChannel]) { 2484 const slideMsg = pack("slide", msg.content, id); 2485 subscribers(codeChannels[targetChannel], slideMsg); 2486 } 2487 } else if (msg.type === "code" && msg.content?.codeChannel) { 2488 // Handle code broadcast to channel subscribers (for kidlisp.com pop-out sync) 2489 const targetChannel = msg.content.codeChannel; 2490 2491 // Store the latest state for late joiners 2492 codeChannelState[targetChannel] = msg.content; 2493 2494 if (codeChannels[targetChannel]) { 2495 // Note: msg.content is already an object, pack() will JSON.stringify it 2496 const codeMsg = pack("code", msg.content, id); 2497 subscribers(codeChannels[targetChannel], codeMsg); 2498 log(`📢 Broadcast code to channel ${targetChannel} (${codeChannels[targetChannel].size} subscribers)`); 2499 } 2500 } else if (msg.type === "login") { 2501 if (msg.content?.user?.sub) { 2502 if (!clients[id]) clients[id] = { websocket: true }; 2503 clients[id].user = msg.content.user.sub; 2504 2505 // Fetch the user's handle from the API 2506 const userSub = msg.content.user.sub; 2507 log("🔑 Login attempt for user:", userSub.substring(0, 20) + "...", "connection:", id); 2508 2509 fetch(`https://aesthetic.computer/handle/${encodeURIComponent(userSub)}`) 2510 .then(response => { 2511 log("📡 Handle API response status:", response.status, "for", userSub.substring(0, 20) + "..."); 2512 return response.json(); 2513 }) 2514 .then(data => { 2515 log("📦 Handle API data:", JSON.stringify(data), "for connection:", id); 2516 if (data.handle) { 2517 clients[id].handle = data.handle; 2518 log("✅ User logged in:", data.handle, `(${userSub.substring(0, 12)}...)`, "connection:", id); 2519 emitProfilePresence(data.handle, "login", ["handle", "online", "connections"]); 2520 } else { 2521 log("⚠️ User logged in (no handle in response):", userSub.substring(0, 12), "..., connection:", id); 2522 } 2523 }) 2524 .catch(err => { 2525 log("❌ Failed to fetch handle for:", userSub.substring(0, 20) + "...", "Error:", err.message); 2526 }); 2527 } 2528 } else if (msg.type === "identify") { 2529 // VSCode extension identifying itself 2530 if (msg.content?.type === "vscode") { 2531 vscodeClients.add(ws); 2532 log("✅ VSCode extension connected, conn:", id); 2533 2534 // Send confirmation 2535 ws.send(pack("identified", { type: "vscode", id }, id)); 2536 } 2537 } else if (msg.type === "dev:log") { 2538 // 📡 Remote log forwarding from connected devices (LAN Dev mode) 2539 if (dev && msg.content) { 2540 const { level, args, deviceName, connectionId, time, queued } = msg.content; 2541 const client = clients[id]; 2542 const deviceLabel = deviceName || client?.ip || `conn:${connectionId}`; 2543 const levelEmoji = level === 'error' ? '🔴' : level === 'warn' ? '🟡' : '🔵'; 2544 const queuedTag = queued ? ' [Q]' : ''; 2545 2546 // Format the log output 2547 const timestamp = new Date(time).toLocaleTimeString(); 2548 const argsStr = Array.isArray(args) ? args.join(' ') : String(args); 2549 2550 console.log(`${levelEmoji} [${timestamp}] ${deviceLabel}${queuedTag}: ${argsStr}`); 2551 } 2552 } else if (msg.type === "location:broadcast") { /* 2553 sub 2554 .subscribe(`logout:broadcast:${msg.content.user.sub}`, () => { 2555 ws.send(pack(`logout:broadcast:${msg.content.user.sub}`, true, id)); 2556 }) 2557 .then(() => { 2558 log("🏃 Subscribed to logout updates from:", msg.content.user.sub); 2559 }) 2560 .catch((err) => 2561 error( 2562 "🏃 Could not unsubscribe from logout:broadcast for:", 2563 msg.content.user.sub, 2564 err, 2565 ), 2566 ); 2567 */ 2568 } else if (msg.type === "logout:broadcast:subscribe") { 2569 /* 2570 console.log("Logout broadcast:", msg.type, msg.content); 2571 pub 2572 .publish(`logout:broadcast:${msg.content.user.sub}`, "true") 2573 .then((result) => { 2574 console.log("🏃 Logout broadcast successful for:", msg.content); 2575 }) 2576 .catch((error) => { 2577 log("🙅‍♀️ Error publishing logout:", error); 2578 }); 2579 */ 2580 } else if (msg.type === "location:broadcast") { 2581 // Receive a slug location for this handle. 2582 if (msg.content.slug !== "*keep-alive*") { 2583 log("🗼 Location:", msg.content.slug, "Handle:", msg.content.handle, "ID:", id); 2584 } 2585 2586 // Store handle and location for this client 2587 if (!clients[id]) clients[id] = { websocket: true }; 2588 const previousLocation = clients[id].location; 2589 2590 // Extract user identity from message 2591 if (msg.content?.user?.sub) { 2592 clients[id].user = msg.content.user.sub; 2593 } 2594 2595 // Extract handle directly from message 2596 if (msg.content.handle) { 2597 clients[id].handle = msg.content.handle; 2598 } 2599 2600 // Extract and store location 2601 if (msg.content.slug) { 2602 // Don't overwrite location with keep-alive 2603 if (msg.content.slug !== "*keep-alive*") { 2604 clients[id].location = msg.content.slug; 2605 log(`📍 Location updated for ${clients[id].handle || id}: "${msg.content.slug}"`); 2606 if (previousLocation !== msg.content.slug) { 2607 emitProfileActivity(msg.content.handle || clients[id].handle, { 2608 type: "piece", 2609 when: Date.now(), 2610 label: `Piece ${msg.content.slug}`, 2611 ref: msg.content.slug, 2612 }); 2613 } 2614 } else { 2615 log(`💓 Keep-alive from ${clients[id].handle || id}, location unchanged`); 2616 } 2617 } 2618 2619 emitProfilePresence( 2620 msg.content.handle || clients[id].handle, 2621 "location:broadcast", 2622 ["online", "currentPiece", "connections"], 2623 ); 2624 2625 // Publish to redis... 2626 pub 2627 .publish("slug:" + msg.content.handle, msg.content.slug) 2628 .then((result) => { 2629 if (msg.content.slug !== "*keep-alive*") { 2630 log( 2631 "🐛 Slug succesfully published for:", 2632 msg.content.handle, 2633 msg.content.slug, 2634 ); 2635 } 2636 }) 2637 .catch((error) => { 2638 log("🙅‍♀️ Error publishing slug:", error); 2639 }); 2640 2641 // TODO: - [] When a user is ghosted, then subscribe to their location 2642 // updates. 2643 // - [] And stop subscribing when they are unghosted. 2644 } else if (msg.type === "dev-log" && dev) { 2645 // Create device-specific log files and only notify in terminal 2646 const timestamp = new Date().toISOString(); 2647 const deviceId = `client-${id}`; 2648 const logFileName = `${DEV_LOG_DIR}${deviceId}.log`; 2649 2650 // Check if this is a new device 2651 if (!deviceLogFiles.has(deviceId)) { 2652 deviceLogFiles.set(deviceId, logFileName); 2653 console.log(`📱 New device logging: ${deviceId} -> ${logFileName}`); 2654 console.log(` tail -f ${logFileName}`); 2655 } 2656 2657 // Write to device-specific log file 2658 const logEntry = `[${timestamp}] ${msg.content.level || 'LOG'}: ${msg.content.message}\n`; 2659 2660 try { 2661 fs.appendFileSync(logFileName, logEntry); 2662 } catch (error) { 2663 console.error(`Failed to write to ${logFileName}:`, error); 2664 } 2665 } else { 2666 // 🗺️ World Messages 2667 // TODO: Should all messages be prefixed with their piece? 2668 2669 // Filter for `world:${piece}:${label}` type messages. 2670 if (msg.type.startsWith("world:")) { 2671 const parsed = msg.type.split(":"); 2672 const piece = parsed[1]; 2673 const label = parsed.pop(); 2674 const worldHandle = resolveProfileHandle(id, piece, msg.content?.handle); 2675 2676 // TODO: Store client position on disconnect, based on their handle. 2677 2678 if (label === "show") { 2679 // Store any existing show picture in clients list. 2680 worldClients[piece][id].showing = msg.content; 2681 emitProfileActivity(worldHandle, { 2682 type: "show", 2683 when: Date.now(), 2684 label: `Showing in ${piece}`, 2685 piece, 2686 ref: piece, 2687 }); 2688 emitProfilePresence(worldHandle, `world:${piece}:show`, ["world", "showing"]); 2689 } 2690 2691 if (label === "hide") { 2692 // Store any existing show picture in clients list. 2693 worldClients[piece][id].showing = null; 2694 emitProfileActivity(worldHandle, { 2695 type: "hide", 2696 when: Date.now(), 2697 label: `Hide in ${piece}`, 2698 piece, 2699 ref: piece, 2700 }); 2701 emitProfilePresence(worldHandle, `world:${piece}:hide`, ["world", "showing"]); 2702 } 2703 2704 // Intercept chats and filter them (skip for laer-klokken). 2705 if (label === "write") { 2706 if (piece !== "laer-klokken") msg.content = filter(msg.content); 2707 const chatText = 2708 typeof msg.content === "string" ? msg.content : msg.content?.text; 2709 if (chatText) { 2710 emitProfileActivity(worldHandle, { 2711 type: "chat", 2712 when: Date.now(), 2713 label: `Chat ${piece}: ${truncateProfileText(chatText, 80)}`, 2714 piece, 2715 ref: piece, 2716 text: chatText, 2717 }); 2718 emitProfileCountDelta(worldHandle, { chats: 1 }); 2719 } 2720 } 2721 2722 if (label === "join") { 2723 if (!worldClients[piece]) worldClients[piece] = {}; 2724 2725 // Check to see if the client handle matches and a connection can 2726 // be reassociated. 2727 2728 let pickedUpConnection = false; 2729 keys(worldClients[piece]).forEach((clientID) => { 2730 // TODO: Break out of this loop early. 2731 const client = worldClients[piece][clientID]; 2732 if ( 2733 client["handle"].startsWith("@") && 2734 client["handle"] === msg.content.handle && 2735 client.ghosted 2736 ) { 2737 // log("👻 Ghosted?", client); 2738 2739 log( 2740 "👻 Unghosting:", 2741 msg.content.handle, 2742 "old id:", 2743 clientID, 2744 "new id:", 2745 id, 2746 ); 2747 pickedUpConnection = true; 2748 2749 client.ghosted = false; 2750 2751 sub 2752 .unsubscribe("slug:" + msg.content.handle) 2753 .then(() => { 2754 log("🐛 Unsubscribed from slug for:", msg.content.handle); 2755 }) 2756 .catch((err) => { 2757 error( 2758 "🐛 Could not unsubscribe from slug for:", 2759 msg.content.handle, 2760 err, 2761 ); 2762 }); 2763 2764 delete worldClients[piece][clientID]; 2765 2766 ws.send(pack(`world:${piece}:list`, worldClients[piece], id)); 2767 2768 // Replace the old client with the new data. 2769 worldClients[piece][id] = { ...msg.content }; 2770 } 2771 }); 2772 2773 if (!pickedUpConnection) 2774 ws.send(pack(`world:${piece}:list`, worldClients[piece], id)); 2775 2776 // ❤️‍🔥 TODO: No need to send the current user back via `list` here. 2777 if (!pickedUpConnection) worldClients[piece][id] = { ...msg.content }; 2778 2779 // ^ Send existing list to just this user. 2780 2781 others(JSON.stringify(msg)); // Alert everyone else about the join. 2782 2783 log("🧩 Clients in piece:", piece, worldClients[piece]); 2784 emitProfileActivity(worldHandle, { 2785 type: "join", 2786 when: Date.now(), 2787 label: `Joined ${piece}`, 2788 piece, 2789 ref: piece, 2790 }); 2791 emitProfilePresence(worldHandle, `world:${piece}:join`, ["world", "connections"]); 2792 return; 2793 } else if (label === "move") { 2794 // log("🚶‍♂️", piece, msg.content); 2795 if (typeof worldClients?.[piece]?.[id] === "object") 2796 worldClients[piece][id].pos = msg.content.pos; 2797 } else { 2798 log(`${label}:`, msg.content); 2799 } 2800 2801 if (label === "persist") { 2802 log("🧮 Persisting this client...", msg.content); 2803 } 2804 2805 // All world: messages are only broadcast to "others", with the 2806 // exception of "write" with relays the filtered message back: 2807 if (label === "write") { 2808 everyone(JSON.stringify(msg)); 2809 } else { 2810 others(JSON.stringify(msg)); 2811 } 2812 return; 2813 } 2814 2815 // 🎮 1v1 game position updates should only go to others (not back to sender) 2816 if (msg.type === "1v1:move") { 2817 // Log occasionally in production for debugging (1 in 100 messages) 2818 if (Math.random() < 0.01) { 2819 log(`🎮 1v1:move relay: ${msg.content?.handle || id} -> ${wss.clients.size - 1} others`); 2820 } 2821 others(JSON.stringify(msg)); 2822 return; 2823 } 2824 2825 // 🎾 Squash game position updates — relay to others only (not back to sender) 2826 if (msg.type === "squash:move") { 2827 others(JSON.stringify(msg)); 2828 return; 2829 } 2830 2831 // 🔊 Audio data from kidlisp.com — relay only to code-channel subscribers 2832 if (msg.type === "audio" && msg.content?.codeChannel) { 2833 const ch = msg.content.codeChannel; 2834 if (codeChannels[ch]) { 2835 subscribers(codeChannels[ch], pack("audio", msg.content, id)); 2836 } 2837 return; 2838 } 2839 2840 // 🎮 1v1 join/state messages - log and relay to everyone 2841 if (msg.type === "1v1:join" || msg.type === "1v1:state") { 2842 log(`🎮 ${msg.type}: ${msg.content?.handle || id} -> all ${wss.clients.size} clients`); 2843 } 2844 2845 // 🎯 Duel messages — routed to DuelManager (server-authoritative) 2846 if (msg.type === "duel:join") { 2847 const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; 2848 if (handle) duelManager.playerJoin(handle, id); 2849 return; 2850 } 2851 if (msg.type === "duel:leave") { 2852 const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; 2853 if (handle) duelManager.playerLeave(handle); 2854 return; 2855 } 2856 if (msg.type === "duel:ping") { 2857 const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2858 if (parsed?.handle) duelManager.handlePing(parsed.handle, parsed.ts, id); 2859 return; 2860 } 2861 if (msg.type === "duel:clientlog") { 2862 const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2863 console.log(`🎯 [CLIENT ${parsed?.handle}] ${parsed?.msg}`, parsed?.bullets?.length > 0 ? JSON.stringify(parsed.bullets) : ""); 2864 return; 2865 } 2866 if (msg.type === "duel:input") { 2867 const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; 2868 if (parsed?.handle) duelManager.receiveInput(parsed.handle, parsed); 2869 return; 2870 } 2871 2872 everyone(JSON.stringify(msg)); // Relay any other message to every user. 2873 } 2874 }); 2875 2876 // More info: https://stackoverflow.com/a/49791634/8146077 2877 ws.on("close", () => { 2878 log("🚪 Someone left:", id, "Online:", wss.clients.size, "🫂"); 2879 const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); 2880 if (departingHandle) duelManager.playerLeave(departingHandle); 2881 removeNotepatMidiSubscriber(id); 2882 2883 // Remove from VSCode clients if present 2884 vscodeClients.delete(ws); 2885 2886 // Remove from DAW devices if present 2887 if (dawDevices.has(id)) { 2888 dawDevices.delete(id); 2889 log(`🎹 DAW device disconnected: ${id} (remaining: ${dawDevices.size})`); 2890 } 2891 if (dawIDEs.has(id)) { 2892 dawIDEs.delete(id); 2893 log(`🎹 DAW IDE disconnected: ${id}`); 2894 } 2895 2896 // Delete the user from the worldClients pieces index. 2897 // keys(worldClients).forEach((piece) => { 2898 // delete worldClients[piece][id]; 2899 // if (keys(worldClients[piece]).length === 0) 2900 // delete worldClients[piece]; 2901 // }); 2902 2903 if (clients[id]?.user) { 2904 const userSub = clients[id].user; 2905 sub 2906 .unsubscribe("logout:broadcast:" + userSub) 2907 .then(() => { 2908 log("🏃 Unsubscribed from logout:broadcast for:", userSub); 2909 }) 2910 .catch((err) => { 2911 error( 2912 "🏃 Could not unsubscribe from logout:broadcast for:", 2913 userSub, 2914 err, 2915 ); 2916 }); 2917 } 2918 2919 // Send a message to everyone else on the server that this client left. 2920 2921 let ghosted = false; 2922 2923 keys(worldClients).forEach((piece) => { 2924 if (worldClients[piece][id]) { 2925 // Turn this client into a ghost, unless it's the last one in the 2926 // world region. 2927 if ( 2928 worldClients[piece][id].handle.startsWith("@") && 2929 keys(worldClients[piece]).length > 1 2930 ) { 2931 const handle = worldClients[piece][id].handle; 2932 log("👻 Ghosted:", handle); 2933 log("World clients after ghosting:", worldClients[piece]); 2934 worldClients[piece][id].ghost = true; 2935 ghosted = true; 2936 2937 function kick() { 2938 log("👢 Kicked:", handle, id); 2939 clearTimeout(kickTimer); 2940 sub 2941 .unsubscribe("slug:" + handle) 2942 .then(() => { 2943 log("🐛 Unsubscribed from slug for:", handle); 2944 }) 2945 .catch((err) => { 2946 error("🐛 Could not unsubscribe from slug for:", handle, err); 2947 }); 2948 // Delete the user from the worldClients pieces index. 2949 delete worldClients[piece][id]; 2950 if (keys(worldClients[piece]).length === 0) 2951 delete worldClients[piece]; 2952 everyone(pack(`world:${piece}:kick`, {}, id)); // Kick this ghost. 2953 } 2954 2955 let kickTimer = setTimeout(kick, 5000); 2956 2957 const worlds = ["field", "horizon"]; // Whitelist for worlds... 2958 // This could eventually be communicated based on a parameter in 2959 // the subscription? 24.03.09.15.05 2960 2961 // Subscribe to slug updates from redis. 2962 sub 2963 .subscribe("slug:" + handle, (slug) => { 2964 if (slug !== "*keep-alive*") { 2965 log(`🐛 ${handle} is now in:`, slug); 2966 if (!worlds.includes(slug)) 2967 everyone(pack(`world:${piece}:slug`, { handle, slug }, id)); 2968 } 2969 2970 if (worlds.includes(slug)) { 2971 kick(); 2972 } else { 2973 clearTimeout(kickTimer); 2974 kickTimer = setTimeout(kick, 5000); 2975 } 2976 // Whitelist slugs here 2977 }) 2978 .then(() => { 2979 log("🐛 Subscribed to slug updates from:", handle); 2980 }) 2981 .catch((err) => 2982 error("🐛 Could not subscribe to slug for:", handle, err), 2983 ); 2984 2985 // Send a message to everyone on the server that this client is a ghost. 2986 everyone(pack(`world:${piece}:ghost`, {}, id)); 2987 } else { 2988 // Delete the user from the worldClients pieces index. 2989 delete worldClients[piece][id]; 2990 if (keys(worldClients[piece]).length === 0) 2991 delete worldClients[piece]; 2992 } 2993 } 2994 }); 2995 2996 // Send a message to everyone else on the server that this client left. 2997 if (!ghosted) everyone(pack("left", { count: wss.clients.size }, id)); 2998 2999 // Delete from the connection index. 3000 delete connections[id]; 3001 3002 // Clean up client record if no longer connected via any protocol 3003 if (clients[id]) { 3004 clients[id].websocket = false; 3005 // If also not connected via UDP, delete the client record entirely 3006 if (!udpChannels[id]) { 3007 delete clients[id]; 3008 } 3009 } 3010 3011 // Clear out the codeChannel if the last user disconnects from it. 3012 if (codeChannel !== undefined) { 3013 codeChannels[codeChannel]?.delete(id); 3014 if (codeChannels[codeChannel]?.size === 0) { 3015 delete codeChannels[codeChannel]; 3016 delete codeChannelState[codeChannel]; // Clean up stored state too 3017 log(`🗑️ Cleaned up empty channel: ${codeChannel}`); 3018 } 3019 } 3020 3021 if (departingHandle) { 3022 emitProfilePresence(departingHandle, "disconnect", ["online", "connections"]); 3023 emitProfileActivity(departingHandle, { 3024 type: "presence", 3025 when: Date.now(), 3026 label: "Disconnected", 3027 }); 3028 } 3029 }); 3030}); 3031 3032// Sends a message to all connected clients. 3033function everyone(string) { 3034 wss.clients.forEach((c) => { 3035 if (c?.readyState === WebSocket.OPEN) c.send(string); 3036 }); 3037} 3038 3039// Sends a message to a particular set of client ids on 3040// this instance that are part of the `subs` Set. 3041function subscribers(subs, msg) { 3042 subs.forEach((connectionId) => { 3043 connections[connectionId]?.send(msg); 3044 }); 3045} 3046 3047// 🎯 Wire DuelManager send functions 3048duelManager.setSendFunctions({ 3049 sendUDP: (channelId, event, data) => { 3050 const entry = udpChannels[channelId]; 3051 if (entry?.channel?.webrtcConnection?.state === "open") { 3052 try { entry.channel.emit(event, data); return true; } catch {} 3053 } 3054 return false; // Signal failure so caller can fall back to WS 3055 }, 3056 sendWS: (wsId, type, content) => { 3057 connections[wsId]?.send(pack(type, JSON.stringify(content), "duel")); 3058 }, 3059 broadcastWS: (type, content) => { 3060 everyone(pack(type, JSON.stringify(content), "duel")); 3061 }, 3062 resolveUdpForHandle: (handle) => { 3063 for (const [id, client] of Object.entries(clients)) { 3064 if (client.handle === handle && udpChannels[id]) return id; 3065 } 3066 return null; 3067 }, 3068}); 3069// #endregion 3070 3071// *** Status WebSocket Stream *** 3072// Track status dashboard clients (separate from game clients) 3073const statusClients = new Set(); 3074// Track targeted profile subscribers by normalized handle key (`@name`) 3075const profileStreamClients = new Map(); 3076const profileLastSeen = new Map(); 3077 3078// *** VSCode Extension Clients *** 3079// Track VSCode extension clients for direct jump message routing 3080const vscodeClients = new Set(); 3081 3082function normalizeProfileHandle(handle) { 3083 if (!handle) return null; 3084 const raw = `${handle}`.trim(); 3085 if (!raw) return null; 3086 return `@${raw.replace(/^@+/, "").toLowerCase()}`; 3087} 3088 3089function normalizeMidiHandle(handle) { 3090 const normalized = normalizeProfileHandle(handle); 3091 return normalized ? normalized.slice(1) : ""; 3092} 3093 3094function notepatMidiSourceKey(handle, machineId) { 3095 const handleKey = normalizeProfileHandle(handle) || "@unknown"; 3096 const machineKey = `${machineId || "unknown"}`.trim() || "unknown"; 3097 return `${handleKey}:${machineKey}`; 3098} 3099 3100function listNotepatMidiSources() { 3101 return [...notepatMidiSources.values()] 3102 .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)) 3103 .map((source) => ({ 3104 handle: source.handle || null, 3105 machineId: source.machineId, 3106 piece: source.piece || "notepat", 3107 lastSeen: source.lastSeen || 0, 3108 lastEvent: source.lastEvent || null, 3109 })); 3110} 3111 3112function sendNotepatMidiSources(ws) { 3113 if (!ws || ws.readyState !== WebSocket.OPEN) return; 3114 try { 3115 ws.send(pack("notepat:midi:sources", { sources: listNotepatMidiSources() }, "midi-relay")); 3116 } catch (err) { 3117 error("🎹 Failed to send notepat midi sources:", err); 3118 } 3119} 3120 3121function removeNotepatMidiSubscriber(id) { 3122 if (id === undefined || id === null) return; 3123 notepatMidiSubscribers.delete(id); 3124} 3125 3126function addNotepatMidiSubscriber(id, ws, filter = {}) { 3127 if (id === undefined || id === null || !ws) return; 3128 3129 notepatMidiSubscribers.set(id, { 3130 ws, 3131 all: filter.all === true, 3132 handle: normalizeMidiHandle(filter.handle), 3133 machineId: filter.machineId ? `${filter.machineId}`.trim() : "", 3134 }); 3135 3136 if (ws.readyState === WebSocket.OPEN) { 3137 ws.send(pack("notepat:midi:subscribed", { 3138 all: filter.all === true, 3139 handle: normalizeMidiHandle(filter.handle) || null, 3140 machineId: filter.machineId ? `${filter.machineId}`.trim() : null, 3141 }, "midi-relay")); 3142 } 3143 3144 sendNotepatMidiSources(ws); 3145} 3146 3147function broadcastNotepatMidiSources() { 3148 for (const [id, sub] of notepatMidiSubscribers) { 3149 if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { 3150 notepatMidiSubscribers.delete(id); 3151 continue; 3152 } 3153 sendNotepatMidiSources(sub.ws); 3154 } 3155} 3156 3157function notepatMidiSubscriberMatches(sub, event) { 3158 if (!sub) return false; 3159 if (sub.all) return true; 3160 3161 const eventHandle = normalizeMidiHandle(event?.handle); 3162 const eventMachine = event?.machineId ? `${event.machineId}`.trim() : ""; 3163 3164 if (sub.handle && sub.handle !== eventHandle) return false; 3165 if (sub.machineId && sub.machineId !== eventMachine) return false; 3166 3167 return !!(sub.handle || sub.machineId); 3168} 3169 3170function broadcastNotepatMidiEvent(event) { 3171 for (const [id, sub] of notepatMidiSubscribers) { 3172 if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { 3173 notepatMidiSubscribers.delete(id); 3174 continue; 3175 } 3176 if (!notepatMidiSubscriberMatches(sub, event)) continue; 3177 try { 3178 sub.ws.send(pack("notepat:midi", event, "midi-relay")); 3179 } catch (err) { 3180 error("🎹 Failed to fan out notepat midi event:", err); 3181 } 3182 } 3183} 3184 3185function upsertNotepatMidiSource({ handle, machineId, piece, lastEvent, ts, address, port }) { 3186 const cleanHandle = normalizeMidiHandle(handle); 3187 const cleanMachineId = `${machineId || "unknown"}`.trim() || "unknown"; 3188 const key = notepatMidiSourceKey(cleanHandle, cleanMachineId); 3189 const previous = notepatMidiSources.get(key); 3190 const next = { 3191 handle: cleanHandle || null, 3192 machineId: cleanMachineId, 3193 piece: piece || "notepat", 3194 lastSeen: ts || Date.now(), 3195 lastEvent: lastEvent || previous?.lastEvent || null, 3196 address: address || previous?.address || null, 3197 port: port || previous?.port || null, 3198 }; 3199 3200 notepatMidiSources.set(key, next); 3201 3202 if (!previous) { 3203 log(`🎹 Notepat MIDI source online: ${next.handle ? "@" + next.handle : "@unknown"} ${next.machineId}`); 3204 } 3205 3206 if ( 3207 !previous || 3208 previous.handle !== next.handle || 3209 previous.machineId !== next.machineId || 3210 previous.piece !== next.piece 3211 ) { 3212 broadcastNotepatMidiSources(); 3213 } 3214 3215 return next; 3216} 3217 3218function compactProfileText(value) { 3219 return `${value || ""}`.replace(/\s+/g, " ").trim(); 3220} 3221 3222function truncateProfileText(value, max = 100) { 3223 const text = compactProfileText(value); 3224 if (text.length <= max) return text; 3225 return `${text.slice(0, Math.max(0, max - 3))}...`; 3226} 3227 3228function getProfilePresence(handleKey) { 3229 if (!handleKey) return null; 3230 const clientsForStatus = getClientStatus(); 3231 const matched = clientsForStatus.find( 3232 (client) => normalizeProfileHandle(client?.handle) === handleKey, 3233 ); 3234 3235 if (!matched) { 3236 return { 3237 online: false, 3238 currentPiece: null, 3239 worldPiece: null, 3240 showing: null, 3241 connections: { websocket: 0, udp: 0, total: 0 }, 3242 pingMs: null, 3243 lastSeenAt: profileLastSeen.get(handleKey) || null, 3244 }; 3245 } 3246 3247 const now = Date.now(); 3248 profileLastSeen.set(handleKey, now); 3249 3250 const world = matched?.websocket?.worlds?.[0] || null; 3251 3252 return { 3253 online: true, 3254 currentPiece: matched.location || null, 3255 worldPiece: world?.piece || null, 3256 showing: world?.showing || null, 3257 connections: matched.connectionCount || { websocket: 0, udp: 0, total: 0 }, 3258 pingMs: matched?.websocket?.ping || null, 3259 lastSeenAt: now, 3260 }; 3261} 3262 3263function sendProfileStream(ws, type, data) { 3264 if (!ws || ws.readyState !== WebSocket.OPEN) return; 3265 try { 3266 ws.send(JSON.stringify({ type, data, timestamp: Date.now() })); 3267 } catch (err) { 3268 error("👤 Failed to send profile stream event:", err); 3269 } 3270} 3271 3272function broadcastProfileStream(handleKey, type, data) { 3273 const subs = profileStreamClients.get(handleKey); 3274 if (!subs || subs.size === 0) return; 3275 3276 const stale = []; 3277 subs.forEach((ws) => { 3278 if (ws.readyState !== WebSocket.OPEN) { 3279 stale.push(ws); 3280 return; 3281 } 3282 sendProfileStream(ws, type, data); 3283 }); 3284 3285 stale.forEach((ws) => subs.delete(ws)); 3286 if (subs.size === 0) profileStreamClients.delete(handleKey); 3287} 3288 3289function addProfileStreamClient(ws, handle) { 3290 const handleKey = normalizeProfileHandle(handle); 3291 if (!handleKey) return null; 3292 3293 if (!profileStreamClients.has(handleKey)) { 3294 profileStreamClients.set(handleKey, new Set()); 3295 } 3296 3297 profileStreamClients.get(handleKey).add(ws); 3298 ws.profileHandleKey = handleKey; 3299 3300 const presence = getProfilePresence(handleKey); 3301 sendProfileStream(ws, "profile:snapshot", { 3302 handle: handleKey, 3303 presence, 3304 }); 3305 sendProfileStream(ws, "counts:update", { 3306 handle: handleKey, 3307 counts: { 3308 online: presence?.online ? 1 : 0, 3309 connections: presence?.connections?.total || 0, 3310 }, 3311 }); 3312 3313 return handleKey; 3314} 3315 3316function removeProfileStreamClient(ws) { 3317 const handleKey = ws?.profileHandleKey; 3318 if (!handleKey) return; 3319 3320 const subs = profileStreamClients.get(handleKey); 3321 if (!subs) { 3322 ws.profileHandleKey = null; 3323 return; 3324 } 3325 3326 subs.delete(ws); 3327 if (subs.size === 0) profileStreamClients.delete(handleKey); 3328 ws.profileHandleKey = null; 3329} 3330 3331function emitProfilePresence(handle, reason = "update", changed = []) { 3332 const handleKey = normalizeProfileHandle(handle); 3333 if (!handleKey) return; 3334 3335 const presence = getProfilePresence(handleKey); 3336 broadcastProfileStream(handleKey, "presence:update", { 3337 handle: handleKey, 3338 reason, 3339 changed, 3340 presence, 3341 }); 3342 broadcastProfileStream(handleKey, "counts:update", { 3343 handle: handleKey, 3344 counts: { 3345 online: presence?.online ? 1 : 0, 3346 connections: presence?.connections?.total || 0, 3347 }, 3348 }); 3349} 3350 3351function emitProfileCountDelta(handle, delta = {}) { 3352 const handleKey = normalizeProfileHandle(handle); 3353 if (!handleKey) return; 3354 if (!delta || typeof delta !== "object") return; 3355 3356 const cleanDelta = {}; 3357 for (const [key, value] of Object.entries(delta)) { 3358 const amount = Number(value); 3359 if (!Number.isFinite(amount) || amount === 0) continue; 3360 cleanDelta[key] = amount; 3361 } 3362 if (Object.keys(cleanDelta).length === 0) return; 3363 3364 broadcastProfileStream(handleKey, "counts:delta", { 3365 handle: handleKey, 3366 delta: cleanDelta, 3367 }); 3368} 3369 3370function emitProfileActivity(handle, event = {}) { 3371 const handleKey = normalizeProfileHandle(handle); 3372 if (!handleKey) return; 3373 3374 const label = truncateProfileText( 3375 event.label || event.text || event.type || "event", 3376 120, 3377 ); 3378 if (!label) return; 3379 3380 broadcastProfileStream(handleKey, "activity:append", { 3381 handle: handleKey, 3382 event: { 3383 type: event.type || "event", 3384 when: event.when || Date.now(), 3385 label, 3386 ref: event.ref || null, 3387 piece: event.piece || null, 3388 }, 3389 }); 3390} 3391 3392function resolveProfileHandle(id, piece, fromMessage) { 3393 return ( 3394 normalizeProfileHandle(fromMessage) || 3395 normalizeProfileHandle(clients?.[id]?.handle) || 3396 normalizeProfileHandle(worldClients?.[piece]?.[id]?.handle) 3397 ); 3398} 3399 3400chatManager.setActivityEmitter((payload = {}) => { 3401 const handle = payload.handle; 3402 if (payload.event) emitProfileActivity(handle, payload.event); 3403 if (payload.countsDelta) emitProfileCountDelta(handle, payload.countsDelta); 3404}); 3405 3406// Broadcast status updates every 2 seconds 3407setInterval(() => { 3408 if (statusClients.size > 0) { 3409 const status = getFullStatus(); 3410 statusClients.forEach(client => { 3411 if (client.readyState === WebSocket.OPEN) { 3412 try { 3413 client.send(JSON.stringify({ type: 'status', data: status })); 3414 } catch (err) { 3415 error('📊 Failed to send status update:', err); 3416 } 3417 } 3418 }); 3419 } 3420}, 2000); 3421 3422// Broadcast targeted profile heartbeat updates every 2 seconds 3423setInterval(() => { 3424 if (profileStreamClients.size === 0) return; 3425 3426 for (const handleKey of profileStreamClients.keys()) { 3427 const presence = getProfilePresence(handleKey); 3428 broadcastProfileStream(handleKey, "presence:update", { 3429 handle: handleKey, 3430 reason: "heartbeat", 3431 changed: [], 3432 presence, 3433 }); 3434 } 3435}, 2000); 3436 3437// 🧚 UDP Server (using Twilio ICE servers) 3438// #endregion udp 3439 3440// Note: This currently works off of a monolith via `udp.aesthetic.computer` 3441// as the ports are blocked on jamsocket. 3442 3443// geckos.io is imported at top and initialized before server.listen() 3444 3445io.onConnection((channel) => { 3446 // Track this UDP channel 3447 udpChannels[channel.id] = { 3448 connectedAt: Date.now(), 3449 state: channel.webrtcConnection.state, 3450 user: null, 3451 handle: null, 3452 channel: channel, // Store reference for targeted sends 3453 }; 3454 3455 // Get IP address from channel 3456 const udpIp = channel.userData?.address || channel.remoteAddress || null; 3457 3458 log(`🩰 UDP ${channel.id} connected from:`, udpIp || 'unknown'); 3459 3460 // Initialize client record with IP 3461 if (!clients[channel.id]) clients[channel.id] = { udp: true }; 3462 if (udpIp) { 3463 const cleanIp = udpIp.replace('::ffff:', ''); 3464 clients[channel.id].ip = cleanIp; 3465 3466 // Get geolocation for UDP client 3467 const geo = geoip.lookup(cleanIp); 3468 if (geo) { 3469 clients[channel.id].geo = { 3470 country: geo.country, 3471 region: geo.region, 3472 city: geo.city, 3473 timezone: geo.timezone, 3474 ll: geo.ll 3475 }; 3476 log(`🌍 UDP ${channel.id} geolocation:`, geo.city || geo.country); 3477 } 3478 } 3479 3480 // Set a timeout to warn about missing identity 3481 setTimeout(() => { 3482 if (!clients[channel.id]?.user && !clients[channel.id]?.handle) { 3483 log(`⚠️ UDP ${channel.id} has been connected for 10s but hasn't sent identity message`); 3484 } 3485 }, 10000); 3486 3487 // Handle identity message 3488 channel.on("udp:identity", (data) => { 3489 try { 3490 const identity = JSON.parse(data); 3491 log(`🩰 UDP ${channel.id} sent identity:`, JSON.stringify(identity).substring(0, 100)); 3492 3493 // Initialize client record if needed 3494 if (!clients[channel.id]) clients[channel.id] = { udp: true }; 3495 3496 // Extract user identity 3497 if (identity.user?.sub) { 3498 clients[channel.id].user = identity.user.sub; 3499 log(`🩰 UDP ${channel.id} user:`, identity.user.sub.substring(0, 20) + "..."); 3500 } 3501 3502 // Extract handle directly from identity message 3503 if (identity.handle) { 3504 clients[channel.id].handle = identity.handle; 3505 log(`✅ UDP ${channel.id} handle: "${identity.handle}"`); 3506 // Resolve UDP channel for duel if this handle is in a duel 3507 duelManager.resolveUdpChannel(identity.handle, channel.id); 3508 } 3509 } catch (e) { 3510 error(`🩰 Failed to parse identity for ${channel.id}:`, e); 3511 } 3512 }); 3513 3514 channel.onDisconnect(() => { 3515 log(`🩰 ${channel.id} got disconnected`); 3516 delete udpChannels[channel.id]; 3517 fairyThrottle.delete(channel.id); 3518 3519 // Clean up client record if no longer connected via any protocol 3520 if (clients[channel.id]) { 3521 clients[channel.id].udp = false; 3522 // If also not connected via WebSocket, delete the client record entirely 3523 if (!connections[channel.id]) { 3524 delete clients[channel.id]; 3525 } 3526 } 3527 3528 channel.close(); 3529 }); 3530 3531 // 💎 TODO: Make these channel names programmable somehow? 24.12.08.04.12 3532 3533 channel.on("tv", (data) => { 3534 if (channel.webrtcConnection.state === "open") { 3535 try { 3536 channel.room.emit("tv", data); 3537 } catch (err) { 3538 console.warn("Broadcast error:", err); 3539 } 3540 } else { 3541 console.log(channel.webrtcConnection.state); 3542 } 3543 }); 3544 3545 // Just for testing via the aesthetic `udp` piece for now. 3546 channel.on("fairy:point", (data) => { 3547 // See docs here: https://github.com/geckosio/geckos.io#reliable-messages 3548 // TODO: - [] Learn about the differences between channels and rooms. 3549 3550 // log(`🩰 fairy:point - ${data}`); 3551 if (channel.webrtcConnection.state === "open") { 3552 try { 3553 channel.broadcast.emit("fairy:point", data); 3554 // ^ emit the to all channels in the same room except the sender 3555 3556 // Bridge to raw UDP clients (native bare-metal) 3557 try { 3558 const parsed = typeof data === "string" ? JSON.parse(data) : data; 3559 const x = parseFloat(parsed.x) || 0; 3560 const y = parseFloat(parsed.y) || 0; 3561 const handle = parsed.handle || ""; 3562 const hlen = Buffer.byteLength(handle, "utf8"); 3563 const pkt = Buffer.alloc(10 + hlen); 3564 pkt[0] = 0x02; // fairy broadcast 3565 pkt.writeFloatLE(x, 1); 3566 pkt.writeFloatLE(y, 5); 3567 pkt[9] = hlen; 3568 pkt.write(handle, 10, "utf8"); 3569 for (const [, client] of udpClients) { 3570 udpRelay.send(pkt, client.port, client.address); 3571 } 3572 } catch (e) { /* ignore bridge errors */ } 3573 3574 // Publish to Redis for silo firehose visualization (throttled ~10Hz) 3575 const now = Date.now(); 3576 const last = fairyThrottle.get(channel.id) || 0; 3577 if (now - last >= FAIRY_THROTTLE_MS) { 3578 fairyThrottle.set(channel.id, now); 3579 pub.publish("fairy:point", data).catch(() => {}); 3580 } 3581 } catch (err) { 3582 console.warn("Broadcast error:", err); 3583 } 3584 } else { 3585 console.log(channel.webrtcConnection.state); 3586 } 3587 }); 3588 3589 // 🎮 1v1 FPS game position updates over UDP (low latency) 3590 channel.on("1v1:move", (data) => { 3591 if (channel.webrtcConnection.state === "open") { 3592 try { 3593 // Log occasionally for production debugging (1 in 100) 3594 if (Math.random() < 0.01) { 3595 const parsed = typeof data === 'string' ? JSON.parse(data) : data; 3596 log(`🩰 UDP 1v1:move: ${parsed?.handle || channel.id} broadcasting`); 3597 } 3598 // Broadcast position to all other players except sender 3599 channel.broadcast.emit("1v1:move", data); 3600 } catch (err) { 3601 console.warn("1v1:move broadcast error:", err); 3602 } 3603 } 3604 }); 3605 3606 // 🎾 Squash game position updates over UDP (low latency) 3607 channel.on("squash:move", (data) => { 3608 if (channel.webrtcConnection.state === "open") { 3609 try { 3610 channel.broadcast.emit("squash:move", data); 3611 } catch (err) { 3612 console.warn("squash:move broadcast error:", err); 3613 } 3614 } 3615 }); 3616 3617 // 🎯 Duel input over UDP (server-authoritative — NOT relayed, fed to DuelManager) 3618 channel.on("duel:input", (data) => { 3619 if (channel.webrtcConnection.state === "open") { 3620 try { 3621 const parsed = typeof data === "string" ? JSON.parse(data) : data; 3622 // Resolve handle from channel identity OR from message payload 3623 const handle = clients[channel.id]?.handle || parsed.handle; 3624 if (handle) { 3625 duelManager.receiveInput(handle, parsed); 3626 // Also resolve UDP channel if not yet linked 3627 if (!clients[channel.id]?.handle && parsed.handle) { 3628 duelManager.resolveUdpChannel(parsed.handle, channel.id); 3629 } 3630 } 3631 } catch (err) { 3632 console.warn("duel:input error:", err); 3633 } 3634 } 3635 }); 3636 3637 // 🎚️ Slide mode: real-time code value updates via UDP (lowest latency) 3638 channel.on("slide:code", (data) => { 3639 if (channel.webrtcConnection.state === "open") { 3640 try { 3641 // Broadcast to all including sender (room.emit) for sync 3642 channel.room.emit("slide:code", data); 3643 } catch (err) { 3644 console.warn("slide:code broadcast error:", err); 3645 } 3646 } 3647 }); 3648 3649 // 🔊 Audio: real-time audio analysis data via UDP (lowest latency) 3650 channel.on("udp:audio", (data) => { 3651 if (channel.webrtcConnection.state === "open") { 3652 try { 3653 channel.room.emit("udp:audio", data); 3654 } catch (err) { 3655 console.warn("udp:audio broadcast error:", err); 3656 } 3657 } 3658 }); 3659}); 3660 3661// #endregion 3662 3663// --------------------------------------------------------------------------- 3664// 🧚 Raw UDP fairy relay (port 10010) — for native bare-metal clients 3665// Binary packet format: 3666// [1 byte type] [4 float x LE] [4 float y LE] [1 handle_len] [N handle] 3667// Type 0x01 = client→server, 0x02 = server→client broadcast 3668// --------------------------------------------------------------------------- 3669const UDP_FAIRY_PORT = 10010; 3670 3671function handleNotepatMidiUdpPacket(payload, rinfo) { 3672 if (!payload || (payload.type !== "notepat:midi" && payload.type !== "notepat:midi:heartbeat")) { 3673 return false; 3674 } 3675 3676 const now = Date.now(); 3677 const source = upsertNotepatMidiSource({ 3678 handle: payload.handle, 3679 machineId: payload.machineId, 3680 piece: payload.piece || "notepat", 3681 lastEvent: payload.type === "notepat:midi" ? payload.event : "heartbeat", 3682 ts: now, 3683 address: rinfo.address, 3684 port: rinfo.port, 3685 }); 3686 3687 if (!source.handle && !source.machineId) { 3688 return true; 3689 } 3690 3691 if (payload.type === "notepat:midi:heartbeat") { 3692 return true; 3693 } 3694 3695 const rawNote = Number(payload.note); 3696 const rawVelocity = Number(payload.velocity); 3697 const rawChannel = Number(payload.channel); 3698 if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) { 3699 log("🎹 Invalid notepat midi UDP payload:", payload); 3700 return true; 3701 } 3702 3703 let event = payload.event === "note_off" ? "note_off" : "note_on"; 3704 const note = Math.max(0, Math.min(127, Math.round(rawNote))); 3705 const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); 3706 const channel = Math.max(0, Math.min(15, Math.round(rawChannel))); 3707 if (event === "note_on" && velocity === 0) event = "note_off"; 3708 3709 broadcastNotepatMidiEvent({ 3710 type: "notepat:midi", 3711 event, 3712 note, 3713 velocity, 3714 channel, 3715 handle: source.handle, 3716 machineId: source.machineId, 3717 piece: source.piece || "notepat", 3718 ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, 3719 }); 3720 3721 return true; 3722} 3723 3724function pruneNotepatMidiSources() { 3725 const now = Date.now(); 3726 let changed = false; 3727 3728 for (const [key, source] of notepatMidiSources) { 3729 if (now - (source.lastSeen || 0) > UDP_MIDI_SOURCE_TTL_MS) { 3730 notepatMidiSources.delete(key); 3731 changed = true; 3732 } 3733 } 3734 3735 if (changed) broadcastNotepatMidiSources(); 3736} 3737 3738udpRelay.on("message", (msg, rinfo) => { 3739 if (msg.length > 0 && msg[0] === 0x01 && msg.length >= 10) { 3740 const key = `${rinfo.address}:${rinfo.port}`; 3741 const x = msg.readFloatLE(1); 3742 const y = msg.readFloatLE(5); 3743 const hlen = msg[9]; 3744 const handle = msg.slice(10, 10 + hlen).toString("utf8"); 3745 3746 udpClients.set(key, { address: rinfo.address, port: rinfo.port, handle, lastSeen: Date.now() }); 3747 3748 // Build broadcast packet (type 0x02) 3749 const bcast = Buffer.alloc(msg.length); 3750 msg.copy(bcast); 3751 bcast[0] = 0x02; 3752 3753 // Broadcast to all other UDP clients 3754 for (const [k, client] of udpClients) { 3755 if (k !== key) { 3756 udpRelay.send(bcast, client.port, client.address); 3757 } 3758 } 3759 3760 // Also broadcast to Geckos.io WebRTC clients as fairy:point 3761 const fairyData = JSON.stringify({ x, y, handle }); 3762 try { 3763 // Emit to all geckos channels 3764 io.room().emit("fairy:point", fairyData); 3765 } catch (e) { /* ignore */ } 3766 3767 // Publish to Redis for silo firehose (throttled) 3768 const now = Date.now(); 3769 const lastFairy = fairyThrottle.get(key) || 0; 3770 if (now - lastFairy >= FAIRY_THROTTLE_MS) { 3771 fairyThrottle.set(key, now); 3772 pub.publish("fairy:point", fairyData).catch(() => {}); 3773 } 3774 return; 3775 } 3776 3777 if (msg.length > 0 && msg[0] === 0x7b) { 3778 try { 3779 const payload = JSON.parse(msg.toString("utf8")); 3780 if (handleNotepatMidiUdpPacket(payload, rinfo)) return; 3781 } catch (err) { 3782 log("🎹 Failed to parse UDP JSON packet:", err?.message || err); 3783 } 3784 } 3785}); 3786 3787// Clean up stale UDP clients every 30s 3788setInterval(() => { 3789 const now = Date.now(); 3790 for (const [key, client] of udpClients) { 3791 if (now - client.lastSeen > 30000) udpClients.delete(key); 3792 } 3793 pruneNotepatMidiSources(); 3794}, 30000); 3795 3796udpRelay.bind(UDP_FAIRY_PORT, () => { 3797 console.log(`🧚 Raw UDP fairy relay listening on port ${UDP_FAIRY_PORT}`); 3798}); 3799 3800// Bridge: forward Geckos fairy:point to UDP clients 3801// (patched into the existing fairy:point handler above via io.room().emit) 3802// When a Geckos client sends fairy:point, also relay to UDP clients: 3803const origFairyHandler = true; // marker — actual bridging done in channel.on("fairy:point") below 3804 3805// #endregion UDP fairy relay 3806 3807// 🚧 File Watching in Local Development Mode 3808// File watching uses: https://github.com/paulmillr/chokidar 3809if (dev) { 3810 // 1. Watch for local file changes in pieces. 3811 chokidar 3812 .watch("../system/public/aesthetic.computer/disks") 3813 .on("all", (event, path) => { 3814 if (event === "change") { 3815 const piece = path 3816 .split("/") 3817 .pop() 3818 .replace(/\.mjs|\.lisp$/, ""); 3819 everyone(pack("reload", { piece: piece || "*" }, "local")); 3820 } 3821 }); // 2. Watch base system files. 3822 chokidar 3823 .watch([ 3824 "../system/netlify/functions", 3825 "../system/public/privacy-policy.html", 3826 "../system/public/aesthetic-direct.html", 3827 "../system/public/aesthetic.computer/lib", 3828 "../system/public/aesthetic.computer/systems", // This doesn't need a full reload / could just reload the disk module? 3829 "../system/public/aesthetic.computer/boot.mjs", 3830 "../system/public/aesthetic.computer/bios.mjs", 3831 "../system/public/aesthetic.computer/style.css", 3832 "../system/public/kidlisp.com", 3833 "../system/public/l5.aesthetic.computer", 3834 "../system/public/gift.aesthetic.computer", 3835 "../system/public/give.aesthetic.computer", 3836 "../system/public/news.aesthetic.computer", 3837 ]) 3838 .on("all", (event, path) => { 3839 if (event === "change") 3840 everyone(pack("reload", { piece: "*refresh*" }, "local")); 3841 }); 3842 3843 // 2b. Watch prompt files separately (piece reload instead of full refresh) 3844 chokidar 3845 .watch("../system/public/aesthetic.computer/prompts") 3846 .on("all", (event, path) => { 3847 if (event === "change") { 3848 const filename = path.split("/").pop(); 3849 console.log(`🎨 Prompt file changed: ${filename}`); 3850 everyone(pack("reload", { piece: "*piece-reload*" }, "local")); 3851 } 3852 }); 3853 3854 // 3. Watch vscode extension 3855 chokidar.watch("../vscode-extension/out").on("all", (event, path) => { 3856 if (event === "change") 3857 everyone(pack("vscode-extension:reload", { reload: true }, "local")); 3858 }); 3859} 3860 3861/* 3862if (termkit) { 3863 term = termkit.terminal; 3864 3865 const doc = term.createDocument({ 3866 palette: new termkit.Palette(), 3867 }); 3868 3869 // Create left (log) and right (client list) columns 3870 const leftColumn = new termkit.Container({ 3871 parent: doc, 3872 x: 0, 3873 width: "70%", 3874 height: "100%", 3875 }); 3876 3877 const rightColumn = new termkit.Container({ 3878 parent: doc, 3879 x: "70%", 3880 width: "30%", 3881 height: "100%", 3882 }); 3883 3884 term.grabInput(); 3885 3886 console.log("grabbed input"); 3887 3888 term.on("key", function (name, matches, data) { 3889 console.log("'key' event:", name); 3890 3891 // Detect CTRL-C and exit 'manually' 3892 if (name === "CTRL_C") { 3893 process.exit(); 3894 } 3895 }); 3896 3897 term.on("mouse", function (name, data) { 3898 console.log("'mouse' event:", name, data); 3899 }); 3900 3901 // Log box in the left column 3902 const logBox = new termkit.TextBox({ 3903 parent: leftColumn, 3904 content: "Your logs will appear here...\n", 3905 scrollable: true, 3906 vScrollBar: true, 3907 x: 0, 3908 y: 0, 3909 width: "100%", 3910 height: "100%", 3911 mouse: true, // to allow mouse interactions if needed 3912 }); 3913 3914 // Static list box in the right column 3915 const clientList = new termkit.TextBox({ 3916 parent: rightColumn, 3917 content: "Client List:\n", 3918 x: 0, 3919 y: 0, 3920 width: "100%", 3921 height: "100%", 3922 }); 3923 3924 // Example functions to update contents 3925 function addLog(message) { 3926 logBox.setContent(logBox.getContent() + message + "\n"); 3927 // logBox.scrollBottom(); 3928 doc.draw(); 3929 } 3930 3931 function updateClientList(clients) { 3932 clientList.setContent("Client List:\n" + clients.join("\n")); 3933 doc.draw(); 3934 } 3935 3936 // Example usage 3937 addLog("Server started..."); 3938 updateClientList(["Client1", "Client2"]); 3939 3940 // Handle input for graceful exit 3941 // term.grabInput(); 3942 // term.on("key", (key) => { 3943 // if (key === "CTRL_C") { 3944 // process.exit(); 3945 // } 3946 // }); 3947 3948 // doc.draw(); 3949} 3950*/ 3951 3952function log() { 3953 console.log(...arguments); 3954} 3955 3956function error() { 3957 console.error(...arguments); 3958}