Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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