diff --git a/session-server/session.mjs b/session-server/session.mjs index 959fd7b226..88871b45e8 100644 --- a/session-server/session.mjs +++ b/session-server/session.mjs @@ -1,4172 +1,4172 @@ -// Session Server, 23.12.04.14.57 -// Represents a "room" or user or "client" backend -// which at the moment is run once for every "piece" -// that requests it. - -/* #region todo - + Now - - [-] Fix live reloading of in-production udp. - + Done - - [c] `code.channel` should return a promise, and wait for a - `code-channel:subbed`. - event here? This way users get better confirmation if the socket - doesn't go through or if there is a server issue. 23.07.04.18.01 - (Might not actually be that necessary.) - - [x] Add `obscenity` filter. - - [x] Conditional redis sub to dev updates. (Will save bandwidth if extension - gets lots of use, also would be more secure.) - - [x] Secure the "code" path to require a special string. - - [x] Secure the "reload" path (must be in dev mode, sorta okay) - - [c] Speed up developer reload by using redis pub/sub. - - [x] Send a signal to everyone once a user leaves. - - [x] Get "developer" live reloading working again. - - [x] Add sockets back. - - [x] Make a "local" option. - - [x] Read through: https://redis.io/docs/data-types -#endregion */ - -// Add redis pub/sub here... - -import Fastify from "fastify"; -import geckos from "@geckos.io/server"; -import geoip from "geoip-lite"; -import { WebSocket, WebSocketServer } from "ws"; -import ip from "ip"; -import chokidar from "chokidar"; -import fs from "fs"; -import path from "path"; -import crypto from "crypto"; -import dotenv from "dotenv"; -import dgram from "dgram"; -dotenv.config(); - -// Module streaming - path to public directory -const PUBLIC_DIR = path.resolve(process.cwd(), "../system/public/aesthetic.computer"); - -// Module hash cache (invalidated on file change) -const moduleHashes = new Map(); // path -> { hash, content, mtime } - -// Compute hash for a module file -function getModuleHash(modulePath) { - const fullPath = path.join(PUBLIC_DIR, modulePath); - try { - const stats = fs.statSync(fullPath); - const cached = moduleHashes.get(modulePath); - - // Return cached if mtime matches - if (cached && cached.mtime === stats.mtimeMs) { - return cached; - } - - // Read and hash - const content = fs.readFileSync(fullPath, "utf8"); - const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 16); - const entry = { hash, content, mtime: stats.mtimeMs }; - moduleHashes.set(modulePath, entry); - return entry; - } catch (err) { - return null; - } -} - -// Fairy:point throttle (for silo firehose visualization) -const fairyThrottle = new Map(); // channelId -> last publish timestamp -const FAIRY_THROTTLE_MS = 100; // 10Hz max per connection - -// Raw UDP fairy relay (for native bare-metal clients) -const udpRelay = dgram.createSocket("udp4"); -const udpClients = new Map(); // key "ip:port" โ†’ { address, port, handle, lastSeen } -const UDP_MIDI_SOURCE_TTL_MS = 20000; -const notepatMidiSources = new Map(); // key "@handle:machine" -> source metadata -const notepatMidiSubscribers = new Map(); // connection id -> { ws, all, handle, machineId } -// UDP-side subscribers for notepat:midi fan-out over geckos.io. The WS map -// above handles reliable subscription handshakes; this map mirrors the same -// filter model against geckos channels so we can emit events twice (once -// reliably over WS, once low-latency over UDP) to consumers that opened both. -const notepatMidiUdpSubscribers = new Map(); // channel id -> { channel, all, handle, machineId } - -// Error logging ring buffer (for dashboard display) -const errorLog = []; -const MAX_ERRORS = 50; -const ERROR_RETENTION_MS = 60 * 60 * 1000; // 1 hour - -function logError(level, message) { - const entry = { - level, - message: typeof message === 'string' ? message : JSON.stringify(message), - timestamp: new Date().toISOString() - }; - errorLog.push(entry); - if (errorLog.length > MAX_ERRORS) errorLog.shift(); -} - -// Capture uncaught errors -process.on('uncaughtException', (err) => { - logError('error', `Uncaught: ${err.message}`); - console.error('Uncaught Exception:', err); -}); - -process.on('unhandledRejection', (reason, promise) => { - logError('error', `Unhandled Rejection: ${reason}`); - console.error('Unhandled Rejection:', reason); -}); - -import { exec } from "child_process"; - -// FCM (Firebase Cloud Messaging) -import { initializeApp, cert } from "firebase-admin/app"; // Firebase notifications. -//import serviceAccount from "./aesthetic-computer-firebase-adminsdk-79w8j-5b5cdfced8.json" assert { type: "json" }; -import { getMessaging } from "firebase-admin/messaging"; - -let serviceAccount; -try { - const response = await fetch(process.env.GCM_FIREBASE_CONFIG_URL); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - serviceAccount = await response.json(); -} catch (error) { - console.error("Error fetching service account:", error); - // Handle the error as needed -} - -initializeApp( - { credential: cert(serviceAccount) }, //, - //"aesthetic" + ~~performance.now(), -); - -// Initialize ChatManager for multi-instance chat support -const chatManager = new ChatManager({ dev: process.env.NODE_ENV === "development" }); -await chatManager.init(); - -// Graceful shutdown โ€” persist in-memory chat messages before exit -let shuttingDown = false; -async function gracefulShutdown(signal) { - if (shuttingDown) return; - shuttingDown = true; - console.log(`\n${signal} received, persisting chat messages...`); - try { - await chatManager.shutdown(); - } catch (err) { - console.error("Shutdown error:", err); - } - process.exit(0); -} -process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); -process.on("SIGINT", () => gracefulShutdown("SIGINT")); - -// Helper function to get handles of users currently on a specific piece -// Used by chatManager to determine who's actually viewing the chat piece -function getHandlesOnPiece(pieceName) { - const handles = []; - for (const [id, client] of Object.entries(clients)) { - if (client.location === pieceName && client.handle) { - handles.push(client.handle); - } - } - return [...new Set(handles)]; // Remove duplicates -} - -// Expose the function to chatManager -chatManager.setPresenceResolver(getHandlesOnPiece); - -// ๐ŸŽฏ Duel Manager โ€” server-authoritative game for dumduel piece -const duelManager = new DuelManager(); -// ๐ŸŸ๏ธ Arena Manager โ€” Q3-style server-authoritative multiplayer for arena piece -const arenaManager = new ArenaManager(); - -import { filter } from "./filter.mjs"; // Profanity filtering. -import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support. -import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. -import { ArenaManager } from "./arena-manager.mjs"; // Server-authoritative arena game. - -// *** AC Machines โ€” remote device monitoring *** -// Devices connect via /machines?role=device&machineId=X&token=Y -// Viewers connect via /machines?role=viewer&token=Y -import { MongoClient } from "mongodb"; - -const machinesDevices = new Map(); // machineId โ†’ { ws, user, handle, machineId, info, lastHeartbeat } -const machinesViewers = new Map(); // userSub โ†’ Set -let machinesDb = null; - -async function getMachinesDb() { - if (machinesDb) return machinesDb; - const connStr = process.env.MONGODB_CONNECTION_STRING; - if (!connStr) return null; - try { - const client = new MongoClient(connStr); - await client.connect(); - machinesDb = client.db(process.env.MONGODB_NAME || "aesthetic"); - return machinesDb; - } catch (e) { - error("[machines] MongoDB connect error:", e.message); - return null; - } -} - -let machineTokenSecret = null; -let machineTokenSecretAt = 0; -const MACHINE_SECRET_TTL = 5 * 60 * 1000; // refresh from DB every 5 min - -async function loadMachineTokenSecret() { - const now = Date.now(); - if (machineTokenSecret && now - machineTokenSecretAt < MACHINE_SECRET_TTL) { - return machineTokenSecret; - } - try { - const db = await getMachinesDb(); - if (!db) return machineTokenSecret; - const doc = await db.collection("secrets").findOne({ _id: "machine-token" }); - if (doc?.secret) { - machineTokenSecret = doc.secret; - machineTokenSecretAt = now; - } - } catch (e) { - error("[machines] Failed to load machine-token secret:", e.message); - } - return machineTokenSecret; -} - -async function verifyMachineToken(token) { - if (!token) return null; - const secret = await loadMachineTokenSecret(); - if (!secret) return null; - try { - const [payloadB64, sigB64] = token.split("."); - if (!payloadB64 || !sigB64) return null; - const expectedSig = crypto - .createHmac("sha256", secret) - .update(payloadB64) - .digest("base64url"); - if (sigB64 !== expectedSig) return null; - return JSON.parse(Buffer.from(payloadB64, "base64url").toString()); - } catch { - return null; - } -} - -// Verify an AC auth token (Bearer token from authorize()) by calling Auth0 userinfo -async function verifyACToken(token) { - if (!token) return null; - try { - const res = await fetch("https://aesthetic.us.auth0.com/userinfo", { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) return null; - return await res.json(); // { sub, nickname, name, ... } - } catch { - return null; - } -} - -function broadcastToMachineViewers(userSub, msg) { - const viewers = machinesViewers.get(userSub); - if (!viewers) return; - const data = JSON.stringify(msg); - for (const v of viewers) { - if (v.readyState === WebSocket.OPEN) v.send(data); - } -} - -async function upsertMachine(userSub, machineId, info) { - const db = await getMachinesDb(); - if (!db) return; - const col = db.collection("ac-machines"); - const now = new Date(); - await col.updateOne( - { user: userSub, machineId }, - { - $set: { - user: userSub, - machineId, - ...info, - status: "online", - linked: true, - lastSeen: now, - updatedAt: now, - }, - $setOnInsert: { createdAt: now, bootCount: 0 }, - $inc: { bootCount: 1 }, - }, - { upsert: true }, - ); -} - -async function updateMachineHeartbeat(userSub, machineId, uptime, currentPiece) { - const db = await getMachinesDb(); - if (!db) return; - await db.collection("ac-machines").updateOne( - { user: userSub, machineId }, - { $set: { lastSeen: new Date(), uptime, currentPiece, status: "online" } }, - ); -} - -async function insertMachineLog(userSub, machineId, msg) { - const db = await getMachinesDb(); - if (!db) return; - await db.collection("ac-machine-logs").insertOne({ - machineId, - user: userSub, - type: msg.logType || "log", - level: msg.level || "info", - message: msg.message, - data: msg.data || null, - crashInfo: msg.crashInfo || null, - when: msg.when ? new Date(msg.when) : new Date(), - receivedAt: new Date(), - }); -} - -async function setMachineOffline(userSub, machineId) { - const db = await getMachinesDb(); - if (!db) return; - await db.collection("ac-machines").updateOne( - { user: userSub, machineId }, - { $set: { status: "offline", updatedAt: new Date() } }, - ); -} - -// *** SockLogs - Remote console log forwarding from devices *** -// Devices with ?socklogs param send logs via WebSocket -// Viewers (CLI or web) can subscribe to see device logs in real-time -const socklogsDevices = new Map(); // deviceId -> { ws, lastLog, logCount } -const socklogsViewers = new Set(); // Set of viewer WebSockets - -function socklogsBroadcast(deviceId, logEntry) { - const message = JSON.stringify({ - type: 'log', - deviceId, - ...logEntry, - serverTime: Date.now() - }); - for (const viewer of socklogsViewers) { - if (viewer.readyState === WebSocket.OPEN) { - viewer.send(message); - } - } -} - -function socklogsStatus() { - return { - devices: Array.from(socklogsDevices.entries()).map(([id, info]) => ({ - deviceId: id, - logCount: info.logCount, - lastLog: info.lastLog, - connectedAt: info.connectedAt - })), - viewerCount: socklogsViewers.size - }; -} - -import { createClient } from "redis"; -const redisConnectionString = process.env.REDIS_CONNECTION_STRING; -const dev = process.env.NODE_ENV === "development"; - -// Dev log file for remote debugging -const DEV_LOG_FILE = path.join(process.cwd(), "../system/public/aesthetic.computer/dev-logs.txt"); - -const { keys } = Object; -let fastify; //, termkit, term; - -if (dev) { - // Load local ssl certs in development mode. - fastify = Fastify({ - https: { - // allowHTTP1: true, - key: fs.readFileSync("../ssl-dev/localhost-key.pem"), - cert: fs.readFileSync("../ssl-dev/localhost.pem"), - }, - logger: true, - }); - - // Import the `terminal-kit` library if dev is true. - // try { - // termkit = (await import("terminal-kit")).default; - // } catch (err) { - // error("Failed to load terminal-kit", error); - // } -} else { - fastify = Fastify({ logger: true }); // Still log in production. No reason not to? -} - -// Insert `cors` headers as needed. 23.12.19.16.31 -// TODO: Is this even necessary? -fastify.options("*", async (req, reply) => { - const allowedOrigins = [ - "https://aesthetic.local:8888", - "https://aesthetic.computer", - "https://notepat.com", - ]; - - const origin = req.headers.origin; - log("โœˆ๏ธ Preflight origin:", origin); - // Check if the incoming origin is allowed - if (allowedOrigins.includes(origin)) { - reply.header("Access-Control-Allow-Origin", origin); - } - reply.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); - reply.send(); -}); - -const server = fastify.server; - -const DEV_LOG_DIR = "/tmp/dev-logs/"; -const deviceLogFiles = new Map(); // Track which devices have log files - -// Ensure log directory exists -if (dev) { - try { - fs.mkdirSync(DEV_LOG_DIR, { recursive: true }); - } catch (error) { - console.error("Failed to create dev log directory:", error); - } -} - -const info = { - port: process.env.PORT, // 8889 in development via `package.json` - name: process.env.SESSION_BACKEND_ID, - service: process.env.JAMSOCKET_SERVICE, -}; - -const codeChannels = {}; // Used to filter `code` updates from redis to -// clients who explicitly have the channel set. -const codeChannelState = {}; // Store last code sent to each channel for late joiners - -// DAW channel for M4L device โ†” IDE communication -const dawDevices = new Set(); // Connection IDs of /device instances -const dawIDEs = new Set(); // Connection IDs of IDE instances in Ableton mode - -// Unified client tracking: each client has handle, user, location, and connection types -const clients = {}; // Map of connection ID to { handle, user, location, websocket: true/false, udp: true/false } - -// Device naming for local dev (persisted to file) -const DEVICE_NAMES_FILE = path.join(process.cwd(), "../.device-names.json"); -let deviceNames = {}; // Map of IP -> { name, group } -function loadDeviceNames() { - try { - if (fs.existsSync(DEVICE_NAMES_FILE)) { - deviceNames = JSON.parse(fs.readFileSync(DEVICE_NAMES_FILE, 'utf8')); - log("๐Ÿ“ฑ Loaded device names:", Object.keys(deviceNames).length); - } - } catch (e) { - log("๐Ÿ“ฑ Could not load device names:", e.message); - } -} -function saveDeviceNames() { - try { - fs.writeFileSync(DEVICE_NAMES_FILE, JSON.stringify(deviceNames, null, 2)); - } catch (e) { - log("๐Ÿ“ฑ Could not save device names:", e.message); - } -} -if (dev) loadDeviceNames(); - -// Get the dev host machine name -import os from "os"; -const DEV_HOST_NAME = os.hostname(); -const DEV_LAN_IP = (() => { - // First, try to read from /tmp/host-lan-ip (written by entry.fish in devcontainer) - try { - const hostIpFile = '/tmp/host-lan-ip'; - if (fs.existsSync(hostIpFile)) { - const ip = fs.readFileSync(hostIpFile, 'utf-8').trim(); - if (ip && ip.match(/^\d+\.\d+\.\d+\.\d+$/)) { - console.log(`๐Ÿ–ฅ๏ธ Using host LAN IP from ${hostIpFile}: ${ip}`); - return ip; - } - } - } catch (e) { /* ignore */ } - - // Fallback: try to detect from network interfaces - const interfaces = os.networkInterfaces(); - for (const name of Object.keys(interfaces)) { - for (const iface of interfaces[name]) { - if (iface.family === 'IPv4' && !iface.internal && iface.address.startsWith('192.168.')) { - return iface.address; - } - } - } - return null; -})(); -console.log(`๐Ÿ–ฅ๏ธ Dev host: ${DEV_HOST_NAME}, LAN IP: ${DEV_LAN_IP || 'N/A'}`); - -// Helper: Assign device letters (A, B, C...) based on connection order -function getDeviceLetter(connectionId) { - // Get sorted list of connection IDs - const sortedIds = Object.keys(connections) - .map(id => parseInt(id)) - .sort((a, b) => a - b); - const index = sortedIds.indexOf(parseInt(connectionId)); - if (index === -1) return '?'; - // A=65, B=66, etc. Wrap around after Z - return String.fromCharCode(65 + (index % 26)); -} - -// Helper: Find connections by ID, IP, handle, or device letter -function targetClients(target) { - if (target === 'all') { - return Object.entries(connections) - .filter(([id, ws]) => ws?.readyState === WebSocket.OPEN) - .map(([id, ws]) => ({ id: parseInt(id), ws })); - } - - const results = []; - for (const [id, ws] of Object.entries(connections)) { - const client = clients[id]; - const cleanTarget = target.replace('@', ''); - const cleanIp = client?.ip?.replace('::ffff:', ''); - const deviceLetter = getDeviceLetter(id); - - if ( - String(id) === String(target) || - cleanIp === target || - client?.handle === `@${cleanTarget}` || - client?.handle === cleanTarget || - deviceNames[cleanIp]?.name?.toLowerCase() === target.toLowerCase() || - deviceLetter.toLowerCase() === target.toLowerCase() // Match by letter (A, B, C...) - ) { - if (ws?.readyState === WebSocket.OPEN) { - results.push({ id: parseInt(id), ws }); - } - } - } - return results; -} - -// *** Start up two `redis` clients. (One for subscribing, and for publishing) -const redisEnabled = !!redisConnectionString; -const sub = redisEnabled - ? (!dev ? createClient({ url: redisConnectionString }) : createClient()) - : null; -if (sub) sub.on("error", (err) => { - log("๐Ÿ”ด Redis subscriber client error!", err); - logError('error', `Redis sub: ${err.message}`); -}); - -const pub = redisEnabled - ? (!dev ? createClient({ url: redisConnectionString }) : createClient()) - : null; -if (pub) pub.on("error", (err) => { - log("๐Ÿ”ด Redis publisher client error!", err); - logError('error', `Redis pub: ${err.message}`); -}); - -try { - if (sub && pub) { - await sub.connect(); - await pub.connect(); - - await sub.subscribe("code", (message) => { - const parsed = JSON.parse(message); - if (codeChannels[parsed.codeChannel]) { - const msg = pack("code", message, "development"); - subscribers(codeChannels[parsed.codeChannel], msg); - } - }); - - await sub.subscribe("scream", (message) => { - everyone(pack("scream", message, "screamer")); // Socket back to everyone. - }); - } else { - log("โš ๏ธ Redis disabled โ€” code/scream channels unavailable"); - } -} catch (err) { - error("๐Ÿ”ด Could not connect to `redis` instance."); -} - -const secret = process.env.GITHUB_WEBHOOK_SECRET; - -fastify.post("/update", (request, reply) => { - const signature = request.headers["x-hub-signature"]; - const hash = - "sha1=" + - crypto - .createHmac("sha1", secret) - .update(JSON.stringify(request.body)) - .digest("hex"); - - if (hash !== signature) { - reply.status(401).send({ error: "Invalid signature" }); - return; - } - - // log("Path:", process.env.PATH); - - // Restart service in production. - // exec( - // "cd /home/aesthetic-computer/session-server; pm2 stop all; git pull; npm install; pm2 start all", - // (err, stdout, stderr) => { - // if (err) { - // error(`exec error: ${error}`); - // return; - // } - // log(`stdout: ${stdout}`); - // error(`stderr: ${stderr}`); - // }, - // ); - - reply.send({ status: "ok" }); -}); - -// *** Robots.txt - prevent crawling *** -fastify.get("/robots.txt", async (req, reply) => { - reply.type("text/plain"); - return "User-agent: *\nDisallow: /"; -}); - -// *** Module HTTP endpoint - serve modules directly (bypasses Netlify proxy) *** -// Used by boot.mjs on localhost when the main proxy is flaky -fastify.get("/module/*", async (req, reply) => { - const modulePath = req.params["*"]; - const moduleData = getModuleHash(modulePath); - - if (moduleData) { - reply - .header("Content-Type", "application/javascript; charset=utf-8") - .header("Access-Control-Allow-Origin", "*") - .header("Cache-Control", "no-cache") - .send(moduleData.content); - } else { - reply.status(404).send({ error: "Module not found", path: modulePath }); - } -}); - -// *** Build Stream - pipe terminal output to WebSocket clients *** -// Available in both dev and production for build progress streaming -fastify.post("/build-stream", async (req) => { - const line = typeof req.body === 'string' ? req.body : req.body.line || ''; - everyone(pack("build:log", { line, timestamp: Date.now() })); - return { status: "ok" }; -}); - -fastify.post("/build-status", async (req) => { - everyone(pack("build:status", { ...req.body, timestamp: Date.now() })); - return { status: "ok" }; -}); - -// *** FF1 Art Computer Proxy *** -// Proxies displayPlaylist commands to FF1 via direct connection or cloud relay -const FF1_RELAY_URL = "https://artwork-info.feral-file.workers.dev/api/cast"; - -// Load FF1 config from machines.json -function getFF1Config() { - try { - const machinesPath = path.resolve(process.cwd(), "../aesthetic-computer-vault/machines.json"); - const machines = JSON.parse(fs.readFileSync(machinesPath, "utf8")); - return machines.machines?.["ff1-dvveklza"] || null; - } catch (e) { - log("โš ๏ธ Could not load FF1 config from machines.json:", e.message); - return null; - } -} - -// Execute FF1 cast via SSH through MacBook (for devcontainer) -async function castViaSSH(ff1Config, payload) { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); - const execAsync = promisify(exec); - - const ip = ff1Config.ip; - const port = ff1Config.port || 1111; - const payloadJson = JSON.stringify(payload).replace(/'/g, "'\\''"); // Escape for shell - - // SSH through MacBook to reach FF1 on local network - 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}'"`; - - log(`๐Ÿ“ก FF1 cast via SSH: http://${ip}:${port}/api/cast`); - const { stdout, stderr } = await execAsync(sshCmd, { timeout: 15000 }); - - if (stderr && !stdout) { - throw new Error(stderr); - } - - try { - return JSON.parse(stdout); - } catch { - return { raw: stdout }; - } -} - -fastify.post("/ff1/cast", async (req, reply) => { - reply.header("Access-Control-Allow-Origin", "*"); - reply.header("Access-Control-Allow-Methods", "POST, OPTIONS"); - reply.header("Access-Control-Allow-Headers", "Content-Type"); - - const { topicID, apiKey, command, request, useDirect } = req.body || {}; - const ff1Config = getFF1Config(); - - // Build the DP-1 payload - const payload = { - command: command || "displayPlaylist", - request: request || {} - }; - - // Strategy 1: Try direct connection via SSH tunnel (in dev mode) - if (dev && ff1Config?.ip) { - try { - const result = await castViaSSH(ff1Config, payload); - return { success: true, method: "direct-ssh", response: result }; - } catch (sshErr) { - log(`โš ๏ธ FF1 SSH cast failed: ${sshErr.message}`); - // Fall through to cloud relay - } - } - - // Strategy 2: Try direct connection (if useDirect or localhost tunnel is running) - if (useDirect) { - const deviceUrl = `http://localhost:1111/api/cast`; - try { - log(`๐Ÿ“ก FF1 direct cast to ${deviceUrl}`); - const directResponse = await fetch(deviceUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(5000), // 5s timeout - }); - - if (directResponse.ok) { - const result = await directResponse.json(); - return { success: true, method: "direct", response: result }; - } - log(`โš ๏ธ FF1 direct cast failed: ${directResponse.status}`); - } catch (directErr) { - log(`โš ๏ธ FF1 direct connection failed: ${directErr.message}`); - } - } - - // Strategy 3: Use cloud relay with topicID - const relayTopicId = topicID || ff1Config?.topicId; - if (!relayTopicId) { - reply.status(400); - return { - success: false, - error: "No topicID provided and no FF1 config found. Get topicID from your FF1 app settings." - }; - } - - const relayUrl = `${FF1_RELAY_URL}?topicID=${encodeURIComponent(relayTopicId)}`; - - try { - log(`โ˜๏ธ FF1 relay cast to ${relayUrl}`); - const headers = { "Content-Type": "application/json" }; - if (apiKey || ff1Config?.apiKey) { - headers["API-KEY"] = apiKey || ff1Config?.apiKey; - } - - const relayResponse = await fetch(relayUrl, { - method: "POST", - headers, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(10000), // 10s timeout - }); - - const responseText = await relayResponse.text(); - let responseData; - try { - responseData = JSON.parse(responseText); - } catch { - responseData = { raw: responseText }; - } - - if (!relayResponse.ok) { - // Check if relay is down (404 or Cloudflare errors) - if (relayResponse.status === 404 || responseText.includes("error code:")) { - reply.status(503); - return { - success: false, - error: "FF1 cloud relay is unavailable", - hint: "The Feral File relay service appears to be down. Use ac-ff1 tunnel for local development.", - details: responseData - }; - } - reply.status(relayResponse.status); - return { success: false, error: `FF1 relay error: ${relayResponse.status}`, details: responseData }; - } - - return { success: true, method: "relay", response: responseData }; - } catch (relayErr) { - reply.status(500); - return { success: false, error: relayErr.message }; - } -}); - -// FF1 CORS preflight -fastify.options("/ff1/cast", async (req, reply) => { - reply.header("Access-Control-Allow-Origin", "*"); - reply.header("Access-Control-Allow-Methods", "POST, OPTIONS"); - reply.header("Access-Control-Allow-Headers", "Content-Type"); - return ""; -}); - -// *** Chat Log Endpoint (for system logs from other services) *** -fastify.post("/chat/log", async (req, reply) => { - const host = req.headers.host; - // Determine which chat instance based on a header or default to chat-system - const chatHost = req.headers["x-chat-instance"] || "chat-system.aesthetic.computer"; - const instance = chatManager.getInstance(chatHost); - - if (!instance) { - reply.status(404); - return { status: "error", message: "Unknown chat instance" }; - } - - const result = await chatManager.handleLog(instance, req.body, req.headers.authorization); - reply.status(result.status); - return result.body; -}); - -// *** Chat Status Endpoint *** -fastify.get("/chat/status", async (req) => { - return chatManager.getStatus(); -}); - -const PROFILE_SECRET_CACHE_MS = 60 * 1000; -let profileSecretCacheValue = null; -let profileSecretCacheAt = 0; - -function pickProfileStreamSecret(record) { - if (!record || typeof record !== "object") return null; - const candidates = [ - record.secret, - record.token, - record.profileSecret, - record.value, - ]; - for (const raw of candidates) { - if (!raw) continue; - const value = `${raw}`.trim(); - if (value) return value; - } - return null; -} - -function profileSecretsMatch(expected, provided) { - if (!expected || !provided) return false; - const left = Buffer.from(expected); - const right = Buffer.from(provided); - if (left.length !== right.length) return false; - try { - return crypto.timingSafeEqual(left, right); - } catch (_) { - return false; - } -} - -async function resolveProfileStreamSecret() { - const now = Date.now(); - if (profileSecretCacheAt && now - profileSecretCacheAt < PROFILE_SECRET_CACHE_MS) { - return profileSecretCacheValue; - } - - let resolved = null; - try { - if (chatManager?.db) { - const record = await chatManager.db - .collection("secrets") - .findOne({ _id: "profile-stream" }); - resolved = pickProfileStreamSecret(record); - } - } catch (err) { - error("๐Ÿ‘ค Could not load profile-stream secret from MongoDB:", err?.message || err); - } - - if (!resolved) { - const envSecret = `${process.env.PROFILE_STREAM_SECRET || ""}`.trim(); - resolved = envSecret || null; - } - - profileSecretCacheValue = resolved; - profileSecretCacheAt = now; - return profileSecretCacheValue; -} - -// *** Profile Stream Event Ingest *** -// Accepts server-to-server profile events from Netlify functions. -fastify.post("/profile-event", async (req, reply) => { - try { - const expectedSecret = await resolveProfileStreamSecret(); - const providedSecret = `${req.headers["x-profile-secret"] || ""}`.trim() || null; - if (expectedSecret && !profileSecretsMatch(expectedSecret, providedSecret)) { - reply.status(401); - return { ok: false, error: "Unauthorized" }; - } - - const body = req.body || {}; - const handle = body.handle; - const handleKey = normalizeProfileHandle(handle); - if (!handleKey) { - reply.status(400); - return { ok: false, error: "Missing or invalid handle" }; - } - - if (body.event && typeof body.event === "object") { - emitProfileActivity(handleKey, body.event); - } - - if (body.counts && typeof body.counts === "object") { - broadcastProfileStream(handleKey, "counts:update", { - handle: handleKey, - counts: body.counts, - }); - } - - if (body.countsDelta && typeof body.countsDelta === "object") { - emitProfileCountDelta(handleKey, body.countsDelta); - } - - if (body.presence && typeof body.presence === "object") { - broadcastProfileStream(handleKey, "presence:update", { - handle: handleKey, - reason: body.reason || "external", - changed: Array.isArray(body.changed) ? body.changed : [], - presence: body.presence, - }); - } - - return { ok: true }; - } catch (err) { - error("๐Ÿ‘ค profile-event ingest failed:", err); - reply.status(500); - return { ok: false, error: err.message }; - } -}); - -// *** Live Reload of Pieces in Development *** -if (dev) { - fastify.post("/reload", async (req) => { - everyone(pack("reload", req.body, "pieces")); - return { msg: "Reload request sent!", body: req.body }; - }); - - // Jump to a specific piece (navigate) - fastify.post("/jump", async (req) => { - const { piece } = req.body; - - // Broadcast to all browser clients - everyone(pack("jump", { piece }, "pieces")); - - // Send direct message to VSCode extension clients - vscodeClients.forEach(client => { - if (client?.readyState === WebSocket.OPEN) { - client.send(pack("vscode:jump", { piece }, "vscode")); - } - }); - - return { - msg: "Jump request sent!", - piece, - vscodeConnected: vscodeClients.size > 0 - }; - }); - - // GET /devices - List all connected clients with metadata and names - fastify.get("/devices", async () => { - const clientList = getClientStatus(); - // Enhance with device names and letters - const enhanced = clientList.map((c, index) => ({ - ...c, - letter: getDeviceLetter(c.id), - deviceName: deviceNames[c.ip]?.name || null, - deviceGroup: deviceNames[c.ip]?.group || null, - })); - return { - devices: enhanced, - host: { name: DEV_HOST_NAME, ip: DEV_LAN_IP }, - timestamp: Date.now() - }; - }); - - // GET /dev-info - Get dev host info for client overlay - fastify.get("/dev-info", async (req, reply) => { - // Add CORS headers for cross-origin requests from main site - reply.header("Access-Control-Allow-Origin", "*"); - reply.header("Access-Control-Allow-Methods", "GET"); - return { - host: DEV_HOST_NAME, - ip: DEV_LAN_IP, - mode: "LAN Dev", - timestamp: Date.now() - }; - }); - - // POST /jump/:target - Targeted jump (by ID, IP, handle, or device name) - fastify.post("/jump/:target", async (req) => { - const { target } = req.params; - const { piece, ahistorical, alias } = req.body; - - const targeted = targetClients(target); - if (targeted.length === 0) { - return { error: "No matching device", target }; - } - - targeted.forEach(({ ws }) => { - ws.send(pack("jump", { piece, ahistorical, alias }, "pieces")); - }); - - return { - msg: "Targeted jump sent", - piece, - count: targeted.length, - targets: targeted.map(t => t.id) - }; - }); - - // POST /reload/:target - Targeted reload - fastify.post("/reload/:target", async (req) => { - const { target } = req.params; - const targeted = targetClients(target); - - targeted.forEach(({ ws }) => { - ws.send(pack("reload", req.body, "pieces")); - }); - - return { msg: "Targeted reload sent", count: targeted.length }; - }); - - // POST /piece-reload/:target - Targeted KidLisp reload - fastify.post("/piece-reload/:target", async (req) => { - const { target } = req.params; - const { source, createCode, authToken } = req.body; - const targeted = targetClients(target); - - targeted.forEach(({ ws }) => { - ws.send(pack("piece-reload", { source, createCode, authToken }, "kidlisp")); - }); - - return { msg: "Targeted piece-reload sent", count: targeted.length }; - }); - - // POST /device/name - Set a friendly name for a device by IP - fastify.post("/device/name", async (req) => { - const { ip, name, group } = req.body; - if (!ip) return { error: "IP required" }; - - const cleanIp = ip.replace('::ffff:', ''); - if (name) { - deviceNames[cleanIp] = { name, group: group || null, updatedAt: Date.now() }; - } else { - delete deviceNames[cleanIp]; - } - saveDeviceNames(); - - // Notify the device of its new name - const targeted = targetClients(cleanIp); - targeted.forEach(({ ws }) => { - ws.send(pack("dev:identity", { - name, - host: DEV_HOST_NAME, - hostIp: DEV_LAN_IP, - mode: "LAN Dev" - }, "dev")); - }); - - return { - msg: name ? "Device named" : "Device name cleared", - ip: cleanIp, - name, - notified: targeted.length - }; - }); - - // GET /device/names - List all device names - fastify.get("/device/names", async () => { - return { names: deviceNames }; - }); -} - -// *** HTTP Server Initialization *** - -// Track UDP channels manually (geckos.io doesn't expose this) -const udpChannels = {}; - -// ๐Ÿฉฐ Initialize geckos.io BEFORE server starts listening -// Configure for devcontainer/Docker environment: -// - iceServers: Use local TURN server for relay (required in Docker/devcontainer) -// - portRange: constrain UDP to small range that can be exposed from container -// - cors: allow from any origin in dev mode - -// Detect external IP for TURN server (browsers need to reach TURN from outside container) -// In devcontainer, we expose ports to the host, so use the host's LAN IP -// Priority: TURN_HOST env var > DEV_LAN_IP > localhost -const getExternalTurnHost = () => { - // Check for explicitly set TURN host - if (process.env.TURN_HOST) return process.env.TURN_HOST; - // Use the DEV_LAN_IP if available (detected earlier) - if (DEV_LAN_IP) return DEV_LAN_IP; - // Fallback to localhost (won't work for external clients but ok for local testing) - return 'localhost'; -}; - -const turnHost = getExternalTurnHost(); -console.log("๐Ÿฉฐ TURN server host for ICE:", turnHost); - -const devIceServers = [ - { urls: `stun:${turnHost}:3478` }, - { - urls: `turn:${turnHost}:3478`, - username: 'aesthetic', - credential: 'computer123' - }, -]; -const prodIceServers = [ - { urls: 'stun:stun.l.google.com:19302' }, - // TODO: Add production TURN server -]; - -const io = geckos({ - iceServers: dev ? devIceServers : prodIceServers, - // Force relay-only mode in dev to work through container networking - // Direct UDP won't work from host browser to container internal IP - iceTransportPolicy: dev ? 'relay' : 'all', - portRange: { - min: 10000, - max: 10007, - }, - cors: { - allowAuthorization: true, - origin: dev ? "*" : (req) => { - const allowed = ["https://aesthetic.computer", "https://notepat.com", "https://kidlisp.com", "https://pj.kidlisp.com"]; - const reqOrigin = req.headers?.origin; - return allowed.includes(reqOrigin) ? reqOrigin : allowed[0]; - }, - }, -}); -io.addServer(server); // Hook up to the HTTP Server - must be before listen() -console.log("๐Ÿฉฐ Geckos.io server attached to fastify server (UDP ports 10000-10007)"); - -const start = async () => { - try { - if (dev) { - fastify.listen({ - host: "0.0.0.0", // ip.address(), - port: info.port, - }); - } else { - fastify.listen({ host: "0.0.0.0", port: info.port }); - } - } catch (err) { - fastify.log.error(err); - process.exit(1); - } -}; - -await start(); - -// *** Status Page Data Collection *** - -// Get unified client status - user-centric view -function getClientStatus() { - const identityMap = new Map(); // Map by identity (handle or user or IP) - - // Helper to get identity key for a client - const getIdentityKey = (client) => { - // Priority: handle > user > IP (for grouping same person) - if (client.handle) return `handle:${client.handle}`; - if (client.user) return `user:${client.user}`; - if (client.ip) return `ip:${client.ip}`; - return null; - }; - - // Process all WebSocket connections - Object.keys(connections).forEach((id) => { - const client = clients[id] || {}; - const ws = connections[id]; - const identityKey = getIdentityKey(client); - - if (!identityKey) return; // Skip if no identity info - - if (!identityMap.has(identityKey)) { - identityMap.set(identityKey, { - handle: client.handle || null, - location: client.location || null, - ip: client.ip || null, - geo: client.geo || null, - connectionIds: { websocket: [], udp: [] }, - protocols: { websocket: false, udp: false }, - connections: { websocket: [], udp: [] } - }); - } - - const identity = identityMap.get(identityKey); - - // Update with latest info - if (client.handle && !identity.handle) identity.handle = client.handle; - if (client.location) identity.location = client.location; - if (client.ip && !identity.ip) identity.ip = client.ip; - if (client.geo && !identity.geo) identity.geo = client.geo; - - identity.connectionIds.websocket.push(parseInt(id)); - identity.protocols.websocket = true; - identity.connections.websocket.push({ - id: parseInt(id), - alive: ws.isAlive || false, - readyState: ws.readyState, - ping: ws.lastPing || null, - codeChannel: findCodeChannel(parseInt(id)), - worlds: getWorldMemberships(parseInt(id)) - }); - }); - - // Process all UDP connections - Object.keys(udpChannels).forEach((id) => { - const client = clients[id] || {}; - const udp = udpChannels[id]; - const identityKey = getIdentityKey(client); - - if (!identityKey) return; // Skip if no identity info - - if (!identityMap.has(identityKey)) { - identityMap.set(identityKey, { - handle: client.handle || null, - location: client.location || null, - ip: client.ip || null, - geo: client.geo || null, - connectionIds: { websocket: [], udp: [] }, - protocols: { websocket: false, udp: false }, - connections: { websocket: [], udp: [] } - }); - } - - const identity = identityMap.get(identityKey); - - // Update with latest info - if (client.handle && !identity.handle) identity.handle = client.handle; - if (client.location) identity.location = client.location; - if (client.ip && !identity.ip) identity.ip = client.ip; - if (client.geo && !identity.geo) identity.geo = client.geo; - - identity.connectionIds.udp.push(id); - identity.protocols.udp = true; - identity.connections.udp.push({ - id: id, - connectedAt: udp.connectedAt, - state: udp.state || 'unknown' - }); - }); - - // Convert to array and add summary info - return Array.from(identityMap.values()).map(identity => { - const wsCount = identity.connectionIds.websocket.length; - const udpCount = identity.connectionIds.udp.length; - const totalConnections = wsCount + udpCount; - - return { - handle: identity.handle, - location: identity.location, - ip: identity.ip, - geo: identity.geo, - protocols: identity.protocols, - connectionCount: { - websocket: wsCount, - udp: udpCount, - total: totalConnections - }, - // Simplified connection info - just take first of each type for display - websocket: identity.connections.websocket.length > 0 ? identity.connections.websocket[0] : null, - udp: identity.connections.udp.length > 0 ? identity.connections.udp[0] : null, - multipleTabs: totalConnections > 1 - }; - }); -} - -function getWorldMemberships(connectionId) { - const worlds = []; - Object.keys(worldClients).forEach(piece => { - if (worldClients[piece][connectionId]) { - worlds.push({ - piece, - handle: worldClients[piece][connectionId].handle, - showing: worldClients[piece][connectionId].showing, - ghost: worldClients[piece][connectionId].ghost || false, - }); - } - }); - return worlds; -} - -function findCodeChannel(connectionId) { - for (const [channel, subscribers] of Object.entries(codeChannels)) { - if (subscribers.has(connectionId)) return channel; - } - return null; -} - -function getFullStatus() { - const clientList = getClientStatus(); - - // Get chat status with recent messages - const chatStatus = chatManager.getStatus(); - const chatWithMessages = chatStatus.map(instance => { - // Don't expose sotce chat messages โ€” it's a paid subscriber network. - const isSotce = instance.name === "chat-sotce"; - const recentMessages = (!isSotce && instance.messages > 0) - ? chatManager.getRecentMessages(instance.host, 5) - : []; - return { - ...instance, - recentMessages - }; - }); - - // Filter old errors - const cutoff = Date.now() - ERROR_RETENTION_MS; - const recentErrors = errorLog.filter(e => new Date(e.timestamp).getTime() > cutoff); - - return { - timestamp: Date.now(), - server: { - uptime: process.uptime(), - environment: dev ? "development" : "production", - port: info.port, - }, - totals: { - websocket: wss.clients.size, - udp: Object.keys(udpChannels).length, - unique_clients: clientList.length - }, - clients: clientList, - chat: chatWithMessages, - errors: recentErrors.slice(-20).reverse() // Most recent first - }; -} - - -// *** Socket Server Initialization *** -// #region socket -let wss; -let connections = {}; // All active WebSocket connections. -const worldClients = {}; // All connected ๐Ÿง’ to a space like `field`. - -let connectionId = 0; // TODO: Eventually replace with a username arrived at through -// a client <-> server authentication function. - -wss = new WebSocketServer({ server }); -log( - `๐Ÿค– session.aesthetic.computer (${ - dev ? "Development" : "Production" - }) socket: wss://${ip.address()}:${info.port}`, -); - -// *** Status Page Routes (defined after wss initialization) *** -// Status JSON endpoint -fastify.get("/status", async (request, reply) => { - return getFullStatus(); -}); - -// Status dashboard HTML at root -fastify.get("/", async (request, reply) => { - reply.type("text/html"); - return ` - - - - - session-server - - - -
-

๐Ÿงฉ session-server

-
- ๐Ÿ”ด | - Uptime: -- | - Online: 0 | - Chat: 0 -
-
- -
- - - -
- -
-
-
-

๐Ÿง‘โ€๐Ÿ’ป Connected Clients

-
-
-
-

๐Ÿ’ฌ Chat Instances

-
-
-
-
- -
-
-
-

๐Ÿ’ฌ chat-system

-
-
-
-

๐Ÿ• chat-clock

-
-
- -
-
- -
-
-

โš ๏ธ Recent Errors & Warnings

-
-
-
- - - -`; -}); - -// Pack messages into a simple object protocol of `{type, content}`. -function pack(type, content, id) { - return JSON.stringify({ type, content, id }); -} - -// Enable ping-pong behavior to keep connections persistently tracked. -// (In the future could just tie connections to logged in users or -// persistent tokens to keep persistence.) -const interval = setInterval(function ping() { - wss.clients.forEach((client) => { - if (client.isAlive === false) { - return client.terminate(); - } - client.isAlive = false; - client.pingStart = Date.now(); // Start ping timer - client.ping(); - }); -}, 15000); // 15 second pings from server before termination. - -wss.on("close", function close() { - clearInterval(interval); - connections = {}; -}); - -// Construct the server. -wss.on("connection", async (ws, req) => { - const connectionInfo = { - url: req.url, - host: req.headers.host, - origin: req.headers.origin, - userAgent: req.headers['user-agent'], - remoteAddress: req.socket.remoteAddress, - }; - log('๐Ÿ”Œ WebSocket connection received:', JSON.stringify(connectionInfo, null, 2)); - log('๐Ÿ”Œ Total wss.clients.size:', wss.clients.size); - log('๐Ÿ”Œ Current connections count:', Object.keys(connections).length); - - // Route status dashboard WebSocket connections separately - if (req.url === '/status-stream') { - log('๐Ÿ“Š Status dashboard viewer connected from:', req.socket.remoteAddress); - statusClients.add(ws); - - // Mark as dashboard viewer (don't add to game clients) - ws.isDashboardViewer = true; - - // Send initial state - ws.send(JSON.stringify({ - type: 'status', - data: getFullStatus(), - })); - - ws.on('close', () => { - log('๐Ÿ“Š Status dashboard viewer disconnected'); - statusClients.delete(ws); - }); - - ws.on('error', (err) => { - error('๐Ÿ“Š Status dashboard error:', err); - statusClients.delete(ws); - }); - - return; // Don't process as a game client - } - - // Route targeted profile stream connections - if (req.url?.startsWith('/profile-stream')) { - let requestedHandle = null; - try { - const parsedUrl = new URL(req.url, 'http://localhost'); - requestedHandle = parsedUrl.searchParams.get('handle'); - } catch (err) { - error('๐Ÿ‘ค Invalid profile-stream URL:', err); - } - - const key = addProfileStreamClient(ws, requestedHandle); - if (!key) { - ws.send( - JSON.stringify({ - type: 'profile:error', - data: { message: 'Missing or invalid handle query param.' }, - }), - ); - try { ws.close(); } catch (_) {} - return; - } - - log('๐Ÿ‘ค Profile stream viewer connected for:', key, 'from:', req.socket.remoteAddress); - - ws.on('close', () => { - removeProfileStreamClient(ws); - log('๐Ÿ‘ค Profile stream viewer disconnected for:', key); - }); - - ws.on('error', (err) => { - error('๐Ÿ‘ค Profile stream error:', err); - removeProfileStreamClient(ws); - }); - - return; // Don't process as a game client - } - - // Route chat connections to ChatManager based on host - const host = req.headers.host; - if (chatManager.isChatHost(host)) { - log('๐Ÿ’ฌ Chat client connection from:', host); - chatManager.handleConnection(ws, req); - return; // Don't process as a game client - } - - // Route AC Machines connections โ€” device monitoring & remote commands - if (req.url.startsWith('/machines')) { - const urlParams = new URL(req.url, 'http://localhost').searchParams; - const role = urlParams.get('role') || 'device'; - const token = urlParams.get('token') || ''; - const machineId = urlParams.get('machineId') || ''; - - if (role === 'viewer') { - // Browser dashboard viewer โ€” verify AC auth token via Auth0 - const authUser = await verifyACToken(token); - if (!authUser?.sub) { - ws.close(4001, 'Unauthorized'); - return; - } - const userSub = authUser.sub; - const userHandle = authUser.nickname || authUser.name || null; - - log(`Machines viewer connected: ${userHandle || userSub}`); - - if (!machinesViewers.has(userSub)) machinesViewers.set(userSub, new Set()); - machinesViewers.get(userSub).add(ws); - - // Send initial state: all online machines for this user - const userMachines = []; - for (const [mid, device] of machinesDevices) { - if (device.user === userSub) { - userMachines.push({ - machineId: mid, - ...device.info, - status: "online", - lastHeartbeat: device.lastHeartbeat, - }); - } - } - ws.send(JSON.stringify({ type: "machines-state", machines: userMachines })); - - // Handle viewer โ†’ device commands - ws.on('message', (data) => { - try { - const msg = JSON.parse(data.toString()); - if (msg.type === "command" && msg.machineId) { - const device = machinesDevices.get(msg.machineId); - if (device && device.user === userSub && device.ws.readyState === WebSocket.OPEN) { - const commandId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); - device.ws.send(JSON.stringify({ - type: "command", - command: msg.cmd, - commandId, - target: msg.args?.target || msg.args?.piece || undefined, - // Free-text payload for cmd:"prompt" โ€” runs through the - // device's prompt.mjs execute() exactly as if typed locally. - text: typeof msg.args?.text === "string" ? msg.args.text : undefined, - })); - log(`Command '${msg.cmd}' โ†’ ${msg.machineId} (${commandId})`); - } - } - // Swank eval: forward CL expression to device for evaluation - if (msg.type === "swank:eval" && msg.machineId && msg.expr) { - const device = machinesDevices.get(msg.machineId); - if (device && device.user === userSub && device.ws.readyState === WebSocket.OPEN) { - const evalId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); - device.ws.send(JSON.stringify({ - type: "swank:eval", - expr: msg.expr, - evalId, - })); - log(`๐Ÿ”ฎ Swank eval โ†’ ${msg.machineId}: ${msg.expr.slice(0, 60)}`); - } - } - } catch (e) { - error('๐Ÿ–ฅ๏ธ Machines viewer message error:', e); - } - }); - - ws.on('close', () => { - log(`๐Ÿ–ฅ๏ธ Machines viewer disconnected: ${userHandle || userSub}`); - const viewers = machinesViewers.get(userSub); - if (viewers) { - viewers.delete(ws); - if (viewers.size === 0) machinesViewers.delete(userSub); - } - }); - - ws.on('error', (err) => { - error('๐Ÿ–ฅ๏ธ Machines viewer error:', err); - const viewers = machinesViewers.get(userSub); - if (viewers) { - viewers.delete(ws); - if (viewers.size === 0) machinesViewers.delete(userSub); - } - }); - - } else { - // Device connection - const tokenPayload = await verifyMachineToken(token); - const userSub = tokenPayload?.sub || null; - const userHandle = tokenPayload?.handle || null; - const linked = !!tokenPayload; - - log(`๐Ÿ“ก Machines device connected: ${machineId} (${linked ? userHandle : 'unlinked'})`); - - machinesDevices.set(machineId, { - ws, user: userSub, handle: userHandle, machineId, linked, - info: {}, lastHeartbeat: Date.now(), - }); - - if (userSub) { - broadcastToMachineViewers(userSub, { type: "device-connected", machineId, linked }); - } - - ws.on('message', async (data) => { - try { - const msg = JSON.parse(data.toString()); - const device = machinesDevices.get(machineId); - if (!device) return; - - switch (msg.type) { - case "register": - device.info = { - version: msg.version, buildName: msg.buildName, - gitHash: msg.gitHash, buildTs: msg.buildTs, - hw: msg.hw, ip: msg.ip, wifiSSID: msg.wifiSSID, - hostname: msg.hostname, label: msg.label, - currentPiece: msg.currentPiece || "notepat", - }; - device.lastHeartbeat = Date.now(); - try { await upsertMachine(userSub, machineId, device.info); } catch (e) { error("๐Ÿ“ก upsert:", e.message); } - if (userSub) broadcastToMachineViewers(userSub, { type: "machine-registered", machineId, ...device.info, status: "online" }); - break; - - case "heartbeat": - device.lastHeartbeat = Date.now(); - device.info.uptime = msg.uptime; - device.info.currentPiece = msg.currentPiece || device.info.currentPiece; - device.info.battery = msg.battery; - device.info.charging = msg.charging; - device.info.fps = msg.fps; - try { await updateMachineHeartbeat(userSub, machineId, msg.uptime, device.info.currentPiece); } catch (e) { error("๐Ÿ“ก heartbeat:", e.message); } - if (userSub) broadcastToMachineViewers(userSub, { - type: "heartbeat", machineId, uptime: msg.uptime, - currentPiece: device.info.currentPiece, - battery: msg.battery, charging: msg.charging, fps: msg.fps, - timestamp: Date.now(), - }); - break; - - case "log": - try { await insertMachineLog(userSub, machineId, msg); } catch (e) { error("๐Ÿ“ก log insert:", e.message); } - if (userSub) { - const logMessage = msg.message || (typeof msg.data === "string" ? msg.data : JSON.stringify(msg.data)); - broadcastToMachineViewers(userSub, { - type: "log", machineId, level: msg.logType === "crash" ? "error" : (msg.level || "info"), - message: logMessage, logType: msg.logType || "log", - data: msg.data || null, - when: msg.when || new Date().toISOString(), - }); - } - break; - - case "command-ack": - case "command-response": - if (userSub) broadcastToMachineViewers(userSub, { type: msg.type, machineId, commandId: msg.commandId, command: msg.command, data: msg.data }); - break; - - case "swank:result": - // Forward Swank eval result from device to viewer - if (userSub) broadcastToMachineViewers(userSub, { - type: "swank:result", machineId, - evalId: msg.evalId, ok: msg.ok, result: msg.result, - }); - break; - } - } catch (e) { - error('๐Ÿ“ก Machines device message error:', e); - } - }); - - ws.on('close', async () => { - log(`๐Ÿ“ก Machines device disconnected: ${machineId}`); - machinesDevices.delete(machineId); - if (userSub) { - broadcastToMachineViewers(userSub, { type: "status-change", machineId, status: "offline" }); - try { await setMachineOffline(userSub, machineId); } catch (e) { error("๐Ÿ“ก offline:", e.message); } - } - }); - - ws.on('error', (err) => { - error(`๐Ÿ“ก Machines device error (${machineId}):`, err); - machinesDevices.delete(machineId); - }); - } - - return; // Don't process as a game client - } - - // Route socklogs connections - devices sending logs and viewers subscribing - if (req.url.startsWith('/socklogs')) { - const urlParams = new URL(req.url, 'http://localhost').searchParams; - const role = urlParams.get('role') || 'device'; // 'device' or 'viewer' - const deviceId = urlParams.get('deviceId') || `device-${Date.now()}`; - - if (role === 'viewer') { - // Viewer wants to see logs from devices - log('๐Ÿ‘๏ธ SockLogs viewer connected'); - socklogsViewers.add(ws); - - // Send current status - ws.send(JSON.stringify({ - type: 'status', - ...socklogsStatus() - })); - - ws.on('close', () => { - log('๐Ÿ‘๏ธ SockLogs viewer disconnected'); - socklogsViewers.delete(ws); - }); - - ws.on('error', (err) => { - error('๐Ÿ‘๏ธ SockLogs viewer error:', err); - socklogsViewers.delete(ws); - }); - } else { - // Device sending logs - log(`๐Ÿ“ฑ SockLogs device connected: ${deviceId}`); - socklogsDevices.set(deviceId, { - ws, - logCount: 0, - lastLog: null, - connectedAt: Date.now() - }); - - // Notify viewers of new device - for (const viewer of socklogsViewers) { - if (viewer.readyState === WebSocket.OPEN) { - viewer.send(JSON.stringify({ - type: 'device-connected', - deviceId, - status: socklogsStatus() - })); - } - } - - ws.on('message', (data) => { - try { - const msg = JSON.parse(data.toString()); - if (msg.type === 'log') { - const device = socklogsDevices.get(deviceId); - if (device) { - device.logCount++; - device.lastLog = Date.now(); - } - socklogsBroadcast(deviceId, msg); - } - } catch (e) { - error('๐Ÿ“ฑ SockLogs parse error:', e); - } - }); - - ws.on('close', () => { - log(`๐Ÿ“ฑ SockLogs device disconnected: ${deviceId}`); - socklogsDevices.delete(deviceId); - - // Notify viewers - for (const viewer of socklogsViewers) { - if (viewer.readyState === WebSocket.OPEN) { - viewer.send(JSON.stringify({ - type: 'device-disconnected', - deviceId, - status: socklogsStatus() - })); - } - } - }); - - ws.on('error', (err) => { - error(`๐Ÿ“ฑ SockLogs device error (${deviceId}):`, err); - socklogsDevices.delete(deviceId); - }); - } - - return; // Don't process as a game client - } - - log('๐ŸŽฎ Game client connection detected, adding to connections'); - - // Regular game client connection handling below - const ip = req.socket.remoteAddress || "localhost"; // beautify ip - ws.isAlive = true; // For checking persistence between ping-pong messages. - ws.pingStart = null; // Track ping timing - ws.lastPing = null; // Store last measured ping - - ws.on("pong", () => { - ws.isAlive = true; - if (ws.pingStart) { - ws.lastPing = Date.now() - ws.pingStart; - ws.pingStart = null; - } - }); // Receive a pong and stay alive! - - // Assign the conection a unique id. - connections[connectionId] = ws; - const id = connectionId; - let codeChannel; // Used to subscribe to incoming piece code. - - // Initialize client record with IP and geolocation - if (!clients[id]) clients[id] = {}; - clients[id].websocket = true; - - // Clean IP and get geolocation - const cleanIp = ip.replace('::ffff:', ''); - clients[id].ip = cleanIp; - - const geo = geoip.lookup(cleanIp); - if (geo) { - clients[id].geo = { - country: geo.country, - region: geo.region, - city: geo.city, - timezone: geo.timezone, - ll: geo.ll // [latitude, longitude] - }; - log(`๐ŸŒ Geolocation for ${cleanIp}:`, geo.country, geo.region, geo.city); - } else { - log(`๐ŸŒ No geolocation data for ${cleanIp}`); - } - - log("๐Ÿง Someone joined:", `${id}:${ip}`, "Online:", wss.clients.size, "๐Ÿซ‚"); - log("๐ŸŽฎ Added to connections. Total game clients:", Object.keys(connections).length); - - const content = { id, playerCount: wss.clients.size }; - - // Send a message to all other clients except this one. - function others(string) { - wss.clients.forEach((c) => { - if (c !== ws && c?.readyState === WebSocket.OPEN) c.send(string); - }); - } - - // Send a self-connection message back to the client. - ws.send( - pack( - "connected", - JSON.stringify({ ip, playerCount: content.playerCount }), - id, - ), - ); - - // In dev mode, send device identity info for LAN overlay - if (dev) { - const deviceName = deviceNames[cleanIp]?.name || null; - const deviceLetter = getDeviceLetter(id); - const identityPayload = { - name: deviceName, - letter: deviceLetter, - host: DEV_HOST_NAME, - hostIp: DEV_LAN_IP, - mode: "LAN Dev", - connectionId: id, - }; - console.log(`๐Ÿ“ฑ Sending dev:identity to ${cleanIp}:`, identityPayload); - ws.send(pack("dev:identity", identityPayload, "dev")); - } - - // Send a join message to everyone else. - others( - pack( - "joined", - JSON.stringify({ - text: `${connectionId} has joined. Connections open: ${content.playerCount}`, - }), - id, - ), - ); - - connectionId += 1; - - // Relay all incoming messages from this client to everyone else. - ws.on("message", (data) => { - // Parse incoming message and attach client identifier. - let msg; - try { - msg = JSON.parse(data.toString()); - } catch (error) { - console.error("๐Ÿ“š Failed to parse JSON:", error); - return; - } - - // ๐Ÿ“ฆ Module streaming - handle module requests before other processing - if (msg.type === "module:request") { - const modulePath = msg.path; - const withDeps = msg.withDeps === true; // Request all dependencies too - const knownHashes = msg.knownHashes || {}; // Client's cached hashes - - if (withDeps) { - // Recursively gather all dependencies - const modules = {}; - let skippedCount = 0; - - const gatherDeps = (p, fromPath = null) => { - if (modules[p] || modules[p] === null) return; // Already gathered (or marked as cached) - const data = getModuleHash(p); - if (!data) { - // Only warn for top-level not found, not for deps (which might be optional) - if (!fromPath) log(`๐Ÿ“ฆ Module not found: ${p}`); - return; - } - - // Check if client already has this hash cached - if (knownHashes[p] === data.hash) { - modules[p] = null; // Mark as "client has it" - don't send content - skippedCount++; - } else { - modules[p] = { hash: data.hash, content: data.content }; - } - - // Debug: show when gathering specific important modules - if (p.includes('headers') || p.includes('kidlisp')) { - log(`๐Ÿ“ฆ Gathering ${p} (from ${fromPath || 'top'})${knownHashes[p] === data.hash ? ' [cached]' : ''}`); - } - - // Parse static imports from content - match ES module import/export from statements - // This regex only matches valid relative imports ending in .mjs or .js - // Skip commented lines by checking each line doesn't start with // - const staticImportRegex = /^(?!\s*\/\/).*?(?:import|export)\s+(?:[^;]*?\s+from\s+)?["'](\.{1,2}\/[^"'\s]+\.m?js)["']/gm; - let match; - while ((match = staticImportRegex.exec(data.content)) !== null) { - const importPath = match[1]; - // Skip invalid paths - if (importPath.includes('...') || importPath.length > 200) continue; - - // Resolve relative path - const dir = path.dirname(p); - const resolved = path.normalize(path.join(dir, importPath)); - log(`๐Ÿ“ฆ Found dep: ${p} -> ${importPath} (resolved: ${resolved})`); - gatherDeps(resolved, p); - } - - // Parse dynamic imports - import("./path") or import('./path') or import(`./path`) - // Skip commented lines - const dynamicImportRegex = /^(?!\s*\/\/).*?import\s*\(\s*["'`](\.{1,2}\/[^"'`\s]+\.m?js)["'`]\s*\)/gm; - while ((match = dynamicImportRegex.exec(data.content)) !== null) { - const importPath = match[1]; - // Skip invalid paths - if (importPath.includes('...') || importPath.length > 200) continue; - - // Resolve relative path - const dir = path.dirname(p); - const resolved = path.normalize(path.join(dir, importPath)); - gatherDeps(resolved, p); - } - }; - - gatherDeps(modulePath); - - // Filter out null entries (modules client already has) and count - const modulesToSend = {}; - const cachedPaths = []; - for (const [p, data] of Object.entries(modules)) { - if (data === null) { - cachedPaths.push(p); - } else { - modulesToSend[p] = data; - } - } - - const totalModules = Object.keys(modules).length; - const sentModules = Object.keys(modulesToSend).length; - - if (totalModules > 0) { - // Log bundle stats - if (skippedCount > 0) { - log(`๐Ÿ“ฆ Bundle for ${modulePath}: ${sentModules}/${totalModules} sent (${skippedCount} cached)`); - } else { - log(`๐Ÿ“ฆ Bundle for ${modulePath}: ${sentModules} modules`); - } - - ws.send(JSON.stringify({ - type: "module:bundle", - entry: modulePath, - modules: modulesToSend, - cached: cachedPaths // Tell client which paths to use from cache - })); - } else { - ws.send(JSON.stringify({ - type: "module:error", - path: modulePath, - error: "Module not found" - })); - } - } else { - // Single module request (original behavior) - const moduleData = getModuleHash(modulePath); - - if (moduleData) { - ws.send(JSON.stringify({ - type: "module:response", - path: modulePath, - hash: moduleData.hash, - content: moduleData.content - })); - log(`๐Ÿ“ฆ Module sent: ${modulePath} (${moduleData.content.length} bytes)`); - } else { - ws.send(JSON.stringify({ - type: "module:error", - path: modulePath, - error: "Module not found" - })); - log(`๐Ÿ“ฆ Module not found: ${modulePath}`); - } - } - return; - } - - if (msg.type === "module:check") { - const modulePath = msg.path; - const clientHash = msg.hash; - const moduleData = getModuleHash(modulePath); - - if (moduleData) { - ws.send(JSON.stringify({ - type: "module:status", - path: modulePath, - changed: moduleData.hash !== clientHash, - hash: moduleData.hash - })); - } else { - ws.send(JSON.stringify({ - type: "module:status", - path: modulePath, - changed: true, - hash: null, - error: "Module not found" - })); - } - return; - } - - if (msg.type === "module:list") { - // Return list of available modules (for prefetching) - const modules = [ - "lib/disk.mjs", - "lib/graph.mjs", - "lib/num.mjs", - "lib/geo.mjs", - "lib/parse.mjs", - "lib/help.mjs", - "lib/text.mjs", - "bios.mjs" - ]; - const moduleInfo = modules.map(p => { - const data = getModuleHash(p); - return data ? { path: p, hash: data.hash, size: data.content.length } : null; - }).filter(Boolean); - - ws.send(JSON.stringify({ - type: "module:list", - modules: moduleInfo - })); - return; - } - - // ๐ŸŽน DAW Channel - M4L device โ†” IDE communication - if (msg.type === "daw:join") { - // Device (kidlisp.com/device) joining to receive code - dawDevices.add(id); - log(`๐ŸŽน DAW device joined: ${id} (total: ${dawDevices.size})`); - ws.send(JSON.stringify({ type: "daw:joined", id })); - return; - } - - if (msg.type === "daw:code") { - // IDE sending code to all connected devices - log(`๐ŸŽน DAW code broadcast from ${id} to ${dawDevices.size} devices`); - const codeMsg = JSON.stringify({ - type: "daw:code", - content: msg.content, - from: id - }); - - // Broadcast to all DAW devices - for (const deviceId of dawDevices) { - const deviceWs = connections[deviceId]; - if (deviceWs && deviceWs.readyState === WebSocket.OPEN) { - deviceWs.send(codeMsg); - log(`๐ŸŽน Sent code to device ${deviceId}`); - } - } - return; - } - - if (msg.type === "notepat:midi:sources") { - sendNotepatMidiSources(ws); - return; - } - - if (msg.type === "notepat:midi:subscribe") { - const filter = msg.content || {}; - addNotepatMidiSubscriber(id, ws, filter); - return; - } - - if (msg.type === "notepat:midi:unsubscribe") { - removeNotepatMidiSubscriber(id); - if (ws.readyState === WebSocket.OPEN) { - ws.send(pack("notepat:midi:unsubscribed", true, "midi-relay")); - } - return; - } - - // ๐ŸŽน Browser-side notepat (notepat.com on web) publishes MIDI events - // via WS. Native bare-metal sends the same payload via raw UDP at port - // 10010 (see handleNotepatMidiUdpPacket). Both paths converge through - // broadcastNotepatMidiEvent, fanning out to WS + UDP subscribers (M4L - // notepat-remote devices listen on those channels). Heartbeats keep - // the source visible in `notepat:midi:sources` between notes. - if (msg.type === "notepat:midi:publish" || msg.type === "notepat:midi:heartbeat") { - const payload = msg.content || {}; - const now = Date.now(); - const source = upsertNotepatMidiSource({ - handle: payload.handle, - machineId: payload.machineId, - piece: payload.piece || "notepat", - lastEvent: msg.type === "notepat:midi:heartbeat" ? "heartbeat" : payload.event, - ts: now, - }); - if (msg.type === "notepat:midi:heartbeat") return; - if (!source.handle && !source.machineId) return; - const rawNote = Number(payload.note); - const rawVelocity = Number(payload.velocity); - const rawChannel = Number(payload.channel); - if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) return; - let event = payload.event === "note_off" ? "note_off" : "note_on"; - const note = Math.max(0, Math.min(127, Math.round(rawNote))); - const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); - const channel = Math.max(0, Math.min(15, Math.round(rawChannel))); - if (event === "note_on" && velocity === 0) event = "note_off"; - broadcastNotepatMidiEvent({ - type: "notepat:midi", - event, - note, - velocity, - channel, - handle: source.handle, - machineId: source.machineId, - piece: source.piece || "notepat", - ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, - }); - return; - } - - msg.id = id; // TODO: When sending a server generated message, use a special id. - - // Extract user identity and handle from ANY message that contains it - if (msg.content?.user?.sub) { - if (!clients[id]) clients[id] = { websocket: true }; - - const userSub = msg.content.user.sub; - const userChanged = !clients[id].user || clients[id].user !== userSub; - - if (userChanged) { - clients[id].user = userSub; - log("๐Ÿ”‘ User identity from", msg.type + ":", userSub.substring(0, 20) + "...", "conn:", id); - } - - // Extract handle from message if present (e.g., location:broadcast includes it) - if (msg.content.handle && (!clients[id].handle || clients[id].handle !== msg.content.handle)) { - clients[id].handle = msg.content.handle; - log("โœ… Handle from message:", msg.content.handle, "conn:", id); - emitProfilePresence(msg.content.handle, "identify", ["handle"]); - } - } - - if (msg.type === "scream") { - // Alert all connected users via redis pub/sub to the scream. - log("๐Ÿ˜ฑ About to scream..."); - const out = filter(msg.content); - pub - .publish("scream", out) - .then((result) => { - log("๐Ÿ˜ฑ Scream succesfully published:", result); - - let piece = ""; - if (out.indexOf("pond") > -1) piece = "pond"; - else if (out.indexOf("field") > -1) piece = "field"; - - //if (!dev) { - getMessaging() - .send({ - notification: { - title: "๐Ÿ˜ฑ Scream", - body: out, //, - }, - // android: { - // notification: { - // imageUrl: "https://aesthetic.computer/api/logo.png", - // }, - apns: { - payload: { - aps: { - "mutable-content": 1, - "interruption-level": "time-sensitive", // Marks as time-sensitive - priority: 10, // Highest priority - "content-available": 1, // Tells iOS to wake the app - }, - }, - headers: { - "apns-priority": "10", // Immediate delivery priority - "apns-push-type": "alert", // Explicit push type - "apns-expiration": "0", // Message won't be stored by APNs - }, - fcm_options: { - image: "https://aesthetic.computer/api/logo.png", - }, - }, - webpush: { - headers: { - image: "https://aesthetic.computer/api/logo.png", - }, - }, - topic: "scream", - data: { piece }, - }) - .then((response) => { - log("โ˜Ž๏ธ Successfully sent notification:", response); - }) - .catch((error) => { - log("๐Ÿ“ต Error sending notification:", error); - }); - //} - }) - .catch((error) => { - log("๐Ÿ™…โ€โ™€๏ธ Error publishing scream:", error); - }); - // Send a notification to all devices subscribed to the `scream` topic. - } else if (msg.type === "code-channel:sub") { - // Filter code-channel updates based on this user. - codeChannel = msg.content; - if (!codeChannels[codeChannel]) codeChannels[codeChannel] = new Set(); - codeChannels[codeChannel].add(id); - - // Send current channel state to late joiners - if (codeChannelState[codeChannel]) { - // Note: codeChannelState stores the original msg.content object, - // pack() will JSON.stringify it, so don't double-stringify here - const stateMsg = pack("code", codeChannelState[codeChannel], id); - send(stateMsg); - log(`๐Ÿ“ฅ Sent current state to late joiner on channel ${codeChannel}`); - } - } else if (msg.type === "code-channel:info") { - // Return viewer count for a code channel - const ch = msg.content; - const count = codeChannels[ch]?.size || 0; - send(pack("code-channel:info", { channel: ch, viewers: count }, id)); - } else if (msg.type === "slide" && msg.content?.codeChannel) { - // Handle slide broadcast (low-latency value updates, no state storage) - const targetChannel = msg.content.codeChannel; - - // Don't store slide updates as state (they're transient) - // Just broadcast immediately for low latency - if (codeChannels[targetChannel]) { - const slideMsg = pack("slide", msg.content, id); - subscribers(codeChannels[targetChannel], slideMsg); - } - } else if (msg.type === "code" && msg.content?.codeChannel) { - // Handle code broadcast to channel subscribers (for kidlisp.com pop-out sync) - const targetChannel = msg.content.codeChannel; - - // Store the latest state for late joiners - codeChannelState[targetChannel] = msg.content; - - if (codeChannels[targetChannel]) { - // Note: msg.content is already an object, pack() will JSON.stringify it - const codeMsg = pack("code", msg.content, id); - subscribers(codeChannels[targetChannel], codeMsg); - log(`๐Ÿ“ข Broadcast code to channel ${targetChannel} (${codeChannels[targetChannel].size} subscribers)`); - } - } else if (msg.type === "login") { - if (msg.content?.user?.sub) { - if (!clients[id]) clients[id] = { websocket: true }; - clients[id].user = msg.content.user.sub; - - // Fetch the user's handle from the API - const userSub = msg.content.user.sub; - log("๐Ÿ”‘ Login attempt for user:", userSub.substring(0, 20) + "...", "connection:", id); - - fetch(`https://aesthetic.computer/handle/${encodeURIComponent(userSub)}`) - .then(response => { - log("๐Ÿ“ก Handle API response status:", response.status, "for", userSub.substring(0, 20) + "..."); - return response.json(); - }) - .then(data => { - log("๐Ÿ“ฆ Handle API data:", JSON.stringify(data), "for connection:", id); - if (data.handle) { - clients[id].handle = data.handle; - log("โœ… User logged in:", data.handle, `(${userSub.substring(0, 12)}...)`, "connection:", id); - emitProfilePresence(data.handle, "login", ["handle", "online", "connections"]); - } else { - log("โš ๏ธ User logged in (no handle in response):", userSub.substring(0, 12), "..., connection:", id); - } - }) - .catch(err => { - log("โŒ Failed to fetch handle for:", userSub.substring(0, 20) + "...", "Error:", err.message); - }); - } - } else if (msg.type === "identify") { - // VSCode extension identifying itself - if (msg.content?.type === "vscode") { - vscodeClients.add(ws); - log("โœ… VSCode extension connected, conn:", id); - - // Send confirmation - ws.send(pack("identified", { type: "vscode", id }, id)); - } - } else if (msg.type === "dev:log") { - // ๐Ÿ“ก Remote log forwarding from connected devices (LAN Dev mode) - if (dev && msg.content) { - const { level, args, deviceName, connectionId, time, queued } = msg.content; - const client = clients[id]; - const deviceLabel = deviceName || client?.ip || `conn:${connectionId}`; - const levelEmoji = level === 'error' ? '๐Ÿ”ด' : level === 'warn' ? '๐ŸŸก' : '๐Ÿ”ต'; - const queuedTag = queued ? ' [Q]' : ''; - - // Format the log output - const timestamp = new Date(time).toLocaleTimeString(); - const argsStr = Array.isArray(args) ? args.join(' ') : String(args); - - console.log(`${levelEmoji} [${timestamp}] ${deviceLabel}${queuedTag}: ${argsStr}`); - } - } else if (msg.type === "location:broadcast") { /* - sub - .subscribe(`logout:broadcast:${msg.content.user.sub}`, () => { - ws.send(pack(`logout:broadcast:${msg.content.user.sub}`, true, id)); - }) - .then(() => { - log("๐Ÿƒ Subscribed to logout updates from:", msg.content.user.sub); - }) - .catch((err) => - error( - "๐Ÿƒ Could not unsubscribe from logout:broadcast for:", - msg.content.user.sub, - err, - ), - ); - */ - } else if (msg.type === "logout:broadcast:subscribe") { - /* - console.log("Logout broadcast:", msg.type, msg.content); - pub - .publish(`logout:broadcast:${msg.content.user.sub}`, "true") - .then((result) => { - console.log("๐Ÿƒ Logout broadcast successful for:", msg.content); - }) - .catch((error) => { - log("๐Ÿ™…โ€โ™€๏ธ Error publishing logout:", error); - }); - */ - } else if (msg.type === "location:broadcast") { - // Receive a slug location for this handle. - if (msg.content.slug !== "*keep-alive*") { - log("๐Ÿ—ผ Location:", msg.content.slug, "Handle:", msg.content.handle, "ID:", id); - } - - // Store handle and location for this client - if (!clients[id]) clients[id] = { websocket: true }; - const previousLocation = clients[id].location; - - // Extract user identity from message - if (msg.content?.user?.sub) { - clients[id].user = msg.content.user.sub; - } - - // Extract handle directly from message - if (msg.content.handle) { - clients[id].handle = msg.content.handle; - } - - // Extract and store location - if (msg.content.slug) { - // Don't overwrite location with keep-alive - if (msg.content.slug !== "*keep-alive*") { - clients[id].location = msg.content.slug; - log(`๐Ÿ“ Location updated for ${clients[id].handle || id}: "${msg.content.slug}"`); - if (previousLocation !== msg.content.slug) { - emitProfileActivity(msg.content.handle || clients[id].handle, { - type: "piece", - when: Date.now(), - label: `Piece ${msg.content.slug}`, - ref: msg.content.slug, - }); - } - } else { - log(`๐Ÿ’“ Keep-alive from ${clients[id].handle || id}, location unchanged`); - } - } - - emitProfilePresence( - msg.content.handle || clients[id].handle, - "location:broadcast", - ["online", "currentPiece", "connections"], - ); - - // Publish to redis... - pub - .publish("slug:" + msg.content.handle, msg.content.slug) - .then((result) => { - if (msg.content.slug !== "*keep-alive*") { - log( - "๐Ÿ› Slug succesfully published for:", - msg.content.handle, - msg.content.slug, - ); - } - }) - .catch((error) => { - log("๐Ÿ™…โ€โ™€๏ธ Error publishing slug:", error); - }); - - // TODO: - [] When a user is ghosted, then subscribe to their location - // updates. - // - [] And stop subscribing when they are unghosted. - } else if (msg.type === "dev-log" && dev) { - // Create device-specific log files and only notify in terminal - const timestamp = new Date().toISOString(); - const deviceId = `client-${id}`; - const logFileName = `${DEV_LOG_DIR}${deviceId}.log`; - - // Check if this is a new device - if (!deviceLogFiles.has(deviceId)) { - deviceLogFiles.set(deviceId, logFileName); - console.log(`๐Ÿ“ฑ New device logging: ${deviceId} -> ${logFileName}`); - console.log(` tail -f ${logFileName}`); - } - - // Write to device-specific log file - const logEntry = `[${timestamp}] ${msg.content.level || 'LOG'}: ${msg.content.message}\n`; - - try { - fs.appendFileSync(logFileName, logEntry); - } catch (error) { - console.error(`Failed to write to ${logFileName}:`, error); - } - } else { - // ๐Ÿ—บ๏ธ World Messages - // TODO: Should all messages be prefixed with their piece? - - // Filter for `world:${piece}:${label}` type messages. - if (msg.type.startsWith("world:")) { - const parsed = msg.type.split(":"); - const piece = parsed[1]; - const label = parsed.pop(); - const worldHandle = resolveProfileHandle(id, piece, msg.content?.handle); - - // TODO: Store client position on disconnect, based on their handle. - - if (label === "show") { - // Store any existing show picture in clients list. - worldClients[piece][id].showing = msg.content; - emitProfileActivity(worldHandle, { - type: "show", - when: Date.now(), - label: `Showing in ${piece}`, - piece, - ref: piece, - }); - emitProfilePresence(worldHandle, `world:${piece}:show`, ["world", "showing"]); - } - - if (label === "hide") { - // Store any existing show picture in clients list. - worldClients[piece][id].showing = null; - emitProfileActivity(worldHandle, { - type: "hide", - when: Date.now(), - label: `Hide in ${piece}`, - piece, - ref: piece, - }); - emitProfilePresence(worldHandle, `world:${piece}:hide`, ["world", "showing"]); - } - - // Intercept chats and filter them (skip for laer-klokken). - if (label === "write") { - if (piece !== "laer-klokken") msg.content = filter(msg.content); - const chatText = - typeof msg.content === "string" ? msg.content : msg.content?.text; - if (chatText) { - emitProfileActivity(worldHandle, { - type: "chat", - when: Date.now(), - label: `Chat ${piece}: ${truncateProfileText(chatText, 80)}`, - piece, - ref: piece, - text: chatText, - }); - emitProfileCountDelta(worldHandle, { chats: 1 }); - } - } - - if (label === "join") { - if (!worldClients[piece]) worldClients[piece] = {}; - - // Check to see if the client handle matches and a connection can - // be reassociated. - - let pickedUpConnection = false; - keys(worldClients[piece]).forEach((clientID) => { - // TODO: Break out of this loop early. - const client = worldClients[piece][clientID]; - if ( - client["handle"].startsWith("@") && - client["handle"] === msg.content.handle && - client.ghosted - ) { - // log("๐Ÿ‘ป Ghosted?", client); - - log( - "๐Ÿ‘ป Unghosting:", - msg.content.handle, - "old id:", - clientID, - "new id:", - id, - ); - pickedUpConnection = true; - - client.ghosted = false; - - sub - .unsubscribe("slug:" + msg.content.handle) - .then(() => { - log("๐Ÿ› Unsubscribed from slug for:", msg.content.handle); - }) - .catch((err) => { - error( - "๐Ÿ› Could not unsubscribe from slug for:", - msg.content.handle, - err, - ); - }); - - delete worldClients[piece][clientID]; - - ws.send(pack(`world:${piece}:list`, worldClients[piece], id)); - - // Replace the old client with the new data. - worldClients[piece][id] = { ...msg.content }; - } - }); - - if (!pickedUpConnection) - ws.send(pack(`world:${piece}:list`, worldClients[piece], id)); - - // โค๏ธโ€๐Ÿ”ฅ TODO: No need to send the current user back via `list` here. - if (!pickedUpConnection) worldClients[piece][id] = { ...msg.content }; - - // ^ Send existing list to just this user. - - others(JSON.stringify(msg)); // Alert everyone else about the join. - - log("๐Ÿงฉ Clients in piece:", piece, worldClients[piece]); - emitProfileActivity(worldHandle, { - type: "join", - when: Date.now(), - label: `Joined ${piece}`, - piece, - ref: piece, - }); - emitProfilePresence(worldHandle, `world:${piece}:join`, ["world", "connections"]); - return; - } else if (label === "move") { - // log("๐Ÿšถโ€โ™‚๏ธ", piece, msg.content); - if (typeof worldClients?.[piece]?.[id] === "object") - worldClients[piece][id].pos = msg.content.pos; - } else { - log(`${label}:`, msg.content); - } - - if (label === "persist") { - log("๐Ÿงฎ Persisting this client...", msg.content); - } - - // All world: messages are only broadcast to "others", with the - // exception of "write" with relays the filtered message back: - if (label === "write") { - everyone(JSON.stringify(msg)); - } else { - others(JSON.stringify(msg)); - } - return; - } - - // ๐ŸŽฎ 1v1 game position updates should only go to others (not back to sender) - if (msg.type === "1v1:move") { - // Log occasionally in production for debugging (1 in 100 messages) - if (Math.random() < 0.01) { - log(`๐ŸŽฎ 1v1:move relay: ${msg.content?.handle || id} -> ${wss.clients.size - 1} others`); - } - others(JSON.stringify(msg)); - return; - } - - // ๐ŸŽพ Squash game position updates โ€” relay to others only (not back to sender) - if (msg.type === "squash:move") { - others(JSON.stringify(msg)); - return; - } - - // ๐Ÿ”Š Audio data from kidlisp.com โ€” relay only to code-channel subscribers - if (msg.type === "audio" && msg.content?.codeChannel) { - const ch = msg.content.codeChannel; - if (codeChannels[ch]) { - subscribers(codeChannels[ch], pack("audio", msg.content, id)); - } - return; - } - - // ๐ŸŽฎ 1v1 join/state messages - log and relay to everyone - if (msg.type === "1v1:join" || msg.type === "1v1:state") { - log(`๐ŸŽฎ ${msg.type}: ${msg.content?.handle || id} -> all ${wss.clients.size} clients`); - } - - // ๐ŸŽฏ Duel messages โ€” routed to DuelManager (server-authoritative) - if (msg.type === "duel:join") { - const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; - if (handle) duelManager.playerJoin(handle, id); - return; - } - if (msg.type === "duel:leave") { - const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; - if (handle) duelManager.playerLeave(handle); - return; - } - if (msg.type === "duel:ping") { - const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; - if (parsed?.handle) duelManager.handlePing(parsed.handle, parsed.ts, id); - return; - } - if (msg.type === "duel:clientlog") { - const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; - console.log(`๐ŸŽฏ [CLIENT ${parsed?.handle}] ${parsed?.msg}`, parsed?.bullets?.length > 0 ? JSON.stringify(parsed.bullets) : ""); - return; - } - if (msg.type === "duel:input") { - const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; - if (parsed?.handle) duelManager.receiveInput(parsed.handle, parsed); - return; - } - - // ๐ŸŸ๏ธ Arena messages โ€” routed to ArenaManager (server-authoritative) - if (msg.type === "arena:hello") { - const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; - if (parsed?.handle) { - // Ensure clients[id].handle is set so the WS-close handler can - // look it up and call playerLeave. Without this, probes + any - // client that hasn't sent a chat login message would leak forever. - if (!clients[id]) clients[id] = {}; - clients[id].handle = parsed.handle; - arenaManager.playerJoin(parsed.handle, id, { probe: !!parsed.probe }); - } - return; - } - if (msg.type === "arena:bye") { - const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; - if (parsed?.handle) arenaManager.playerLeave(parsed.handle); - return; - } - if (msg.type === "arena:cmd") { - // WS fallback path; the fast path is the UDP channel.on("arena:cmd", ...) handler. - const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; - if (parsed?.handle) arenaManager.receiveCmd(parsed.handle, parsed); - return; - } - if (msg.type === "arena:ping") { - const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; - if (parsed?.handle) arenaManager.handlePing(parsed.handle, parsed.ts, id); - return; - } - - everyone(JSON.stringify(msg)); // Relay any other message to every user. - } - }); - - // More info: https://stackoverflow.com/a/49791634/8146077 - ws.on("close", () => { - log("๐Ÿšช Someone left:", id, "Online:", wss.clients.size, "๐Ÿซ‚"); - const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); - if (departingHandle) duelManager.playerLeave(departingHandle); - // Arena uses the raw handle (matches arena:hello), not the @-normalized form. - // Pass the closing wsId so a quick reload-race doesn't delete the - // freshly-rebound player (the new hello will have set a different wsId). - const rawDepartingHandle = clients?.[id]?.handle; - if (rawDepartingHandle) arenaManager.playerLeave(rawDepartingHandle, id); - removeNotepatMidiSubscriber(id); - - // Remove from VSCode clients if present - vscodeClients.delete(ws); - - // Remove from DAW devices if present - if (dawDevices.has(id)) { - dawDevices.delete(id); - log(`๐ŸŽน DAW device disconnected: ${id} (remaining: ${dawDevices.size})`); - } - if (dawIDEs.has(id)) { - dawIDEs.delete(id); - log(`๐ŸŽน DAW IDE disconnected: ${id}`); - } - - // Delete the user from the worldClients pieces index. - // keys(worldClients).forEach((piece) => { - // delete worldClients[piece][id]; - // if (keys(worldClients[piece]).length === 0) - // delete worldClients[piece]; - // }); - - if (clients[id]?.user) { - const userSub = clients[id].user; - sub - .unsubscribe("logout:broadcast:" + userSub) - .then(() => { - log("๐Ÿƒ Unsubscribed from logout:broadcast for:", userSub); - }) - .catch((err) => { - error( - "๐Ÿƒ Could not unsubscribe from logout:broadcast for:", - userSub, - err, - ); - }); - } - - // Send a message to everyone else on the server that this client left. - - let ghosted = false; - - keys(worldClients).forEach((piece) => { - if (worldClients[piece][id]) { - // Turn this client into a ghost, unless it's the last one in the - // world region. - if ( - worldClients[piece][id].handle.startsWith("@") && - keys(worldClients[piece]).length > 1 - ) { - const handle = worldClients[piece][id].handle; - log("๐Ÿ‘ป Ghosted:", handle); - log("World clients after ghosting:", worldClients[piece]); - worldClients[piece][id].ghost = true; - ghosted = true; - - function kick() { - log("๐Ÿ‘ข Kicked:", handle, id); - clearTimeout(kickTimer); - sub - .unsubscribe("slug:" + handle) - .then(() => { - log("๐Ÿ› Unsubscribed from slug for:", handle); - }) - .catch((err) => { - error("๐Ÿ› Could not unsubscribe from slug for:", handle, err); - }); - // Delete the user from the worldClients pieces index. - delete worldClients[piece][id]; - if (keys(worldClients[piece]).length === 0) - delete worldClients[piece]; - everyone(pack(`world:${piece}:kick`, {}, id)); // Kick this ghost. - } - - let kickTimer = setTimeout(kick, 5000); - - const worlds = ["field", "horizon"]; // Whitelist for worlds... - // This could eventually be communicated based on a parameter in - // the subscription? 24.03.09.15.05 - - // Subscribe to slug updates from redis. - sub - .subscribe("slug:" + handle, (slug) => { - if (slug !== "*keep-alive*") { - log(`๐Ÿ› ${handle} is now in:`, slug); - if (!worlds.includes(slug)) - everyone(pack(`world:${piece}:slug`, { handle, slug }, id)); - } - - if (worlds.includes(slug)) { - kick(); - } else { - clearTimeout(kickTimer); - kickTimer = setTimeout(kick, 5000); - } - // Whitelist slugs here - }) - .then(() => { - log("๐Ÿ› Subscribed to slug updates from:", handle); - }) - .catch((err) => - error("๐Ÿ› Could not subscribe to slug for:", handle, err), - ); - - // Send a message to everyone on the server that this client is a ghost. - everyone(pack(`world:${piece}:ghost`, {}, id)); - } else { - // Delete the user from the worldClients pieces index. - delete worldClients[piece][id]; - if (keys(worldClients[piece]).length === 0) - delete worldClients[piece]; - } - } - }); - - // Send a message to everyone else on the server that this client left. - if (!ghosted) everyone(pack("left", { count: wss.clients.size }, id)); - - // Delete from the connection index. - delete connections[id]; - - // Clean up client record if no longer connected via any protocol - if (clients[id]) { - clients[id].websocket = false; - // If also not connected via UDP, delete the client record entirely - if (!udpChannels[id]) { - delete clients[id]; - } - } - - // Clear out the codeChannel if the last user disconnects from it. - if (codeChannel !== undefined) { - codeChannels[codeChannel]?.delete(id); - if (codeChannels[codeChannel]?.size === 0) { - delete codeChannels[codeChannel]; - delete codeChannelState[codeChannel]; // Clean up stored state too - log(`๐Ÿ—‘๏ธ Cleaned up empty channel: ${codeChannel}`); - } - } - - if (departingHandle) { - emitProfilePresence(departingHandle, "disconnect", ["online", "connections"]); - emitProfileActivity(departingHandle, { - type: "presence", - when: Date.now(), - label: "Disconnected", - }); - } - }); -}); - -// Sends a message to all connected clients. -function everyone(string) { - wss.clients.forEach((c) => { - if (c?.readyState === WebSocket.OPEN) c.send(string); - }); -} - -// Sends a message to a particular set of client ids on -// this instance that are part of the `subs` Set. -function subscribers(subs, msg) { - subs.forEach((connectionId) => { - connections[connectionId]?.send(msg); - }); -} - -// ๐ŸŽฏ Wire DuelManager send functions -duelManager.setSendFunctions({ - sendUDP: (channelId, event, data) => { - const entry = udpChannels[channelId]; - if (entry?.channel?.webrtcConnection?.state === "open") { - try { entry.channel.emit(event, data); return true; } catch {} - } - return false; // Signal failure so caller can fall back to WS - }, - sendWS: (wsId, type, content) => { - connections[wsId]?.send(pack(type, JSON.stringify(content), "duel")); - }, - broadcastWS: (type, content) => { - everyone(pack(type, JSON.stringify(content), "duel")); - }, - resolveUdpForHandle: (handle) => { - for (const [id, client] of Object.entries(clients)) { - if (client.handle === handle && udpChannels[id]) return id; - } - return null; - }, -}); - -// ๐ŸŸ๏ธ Wire ArenaManager send functions (same shape as DuelManager; separate source tag). -arenaManager.setSendFunctions({ - sendUDP: (channelId, event, data) => { - const entry = udpChannels[channelId]; - if (entry?.channel?.webrtcConnection?.state === "open") { - try { entry.channel.emit(event, data); return true; } catch {} - } - return false; - }, - sendWS: (wsId, type, content) => { - connections[wsId]?.send(pack(type, JSON.stringify(content), "arena")); - }, - broadcastWS: (type, content) => { - everyone(pack(type, JSON.stringify(content), "arena")); - }, - resolveUdpForHandle: (handle) => { - for (const [id, client] of Object.entries(clients)) { - if (client.handle === handle && udpChannels[id]) return id; - } - return null; - }, - // Used to distinguish reconnect (old ws dead โ†’ silent refresh) from a - // real takeover (old ws still alive โ†’ demote to spectator). - isLive: (wsId) => connections[wsId]?.readyState === WebSocket.OPEN, -}); -// #endregion - -// *** Status WebSocket Stream *** -// Track status dashboard clients (separate from game clients) -const statusClients = new Set(); -// Track targeted profile subscribers by normalized handle key (`@name`) -const profileStreamClients = new Map(); -const profileLastSeen = new Map(); - -// *** VSCode Extension Clients *** -// Track VSCode extension clients for direct jump message routing -const vscodeClients = new Set(); - -function normalizeProfileHandle(handle) { - if (!handle) return null; - const raw = `${handle}`.trim(); - if (!raw) return null; - return `@${raw.replace(/^@+/, "").toLowerCase()}`; -} - -function normalizeMidiHandle(handle) { - const normalized = normalizeProfileHandle(handle); - return normalized ? normalized.slice(1) : ""; -} - -function notepatMidiSourceKey(handle, machineId) { - const handleKey = normalizeProfileHandle(handle) || "@unknown"; - const machineKey = `${machineId || "unknown"}`.trim() || "unknown"; - return `${handleKey}:${machineKey}`; -} - -function listNotepatMidiSources() { - return [...notepatMidiSources.values()] - .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)) - .map((source) => ({ - handle: source.handle || null, - machineId: source.machineId, - piece: source.piece || "notepat", - lastSeen: source.lastSeen || 0, - lastEvent: source.lastEvent || null, - })); -} - -function sendNotepatMidiSources(ws) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - ws.send(pack("notepat:midi:sources", { sources: listNotepatMidiSources() }, "midi-relay")); - } catch (err) { - error("๐ŸŽน Failed to send notepat midi sources:", err); - } -} - -function removeNotepatMidiSubscriber(id) { - if (id === undefined || id === null) return; - notepatMidiSubscribers.delete(id); -} - -function addNotepatMidiSubscriber(id, ws, filter = {}) { - if (id === undefined || id === null || !ws) return; - - notepatMidiSubscribers.set(id, { - ws, - all: filter.all === true, - handle: normalizeMidiHandle(filter.handle), - machineId: filter.machineId ? `${filter.machineId}`.trim() : "", - }); - - if (ws.readyState === WebSocket.OPEN) { - ws.send(pack("notepat:midi:subscribed", { - all: filter.all === true, - handle: normalizeMidiHandle(filter.handle) || null, - machineId: filter.machineId ? `${filter.machineId}`.trim() : null, - }, "midi-relay")); - } - - sendNotepatMidiSources(ws); -} - -function broadcastNotepatMidiSources() { - for (const [id, sub] of notepatMidiSubscribers) { - if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { - notepatMidiSubscribers.delete(id); - continue; - } - sendNotepatMidiSources(sub.ws); - } -} - -function notepatMidiSubscriberMatches(sub, event) { - if (!sub) return false; - if (sub.all) return true; - - const eventHandle = normalizeMidiHandle(event?.handle); - const eventMachine = event?.machineId ? `${event.machineId}`.trim() : ""; - - if (sub.handle && sub.handle !== eventHandle) return false; - if (sub.machineId && sub.machineId !== eventMachine) return false; - - return !!(sub.handle || sub.machineId); -} - -function broadcastNotepatMidiEvent(event) { - for (const [id, sub] of notepatMidiSubscribers) { - if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { - notepatMidiSubscribers.delete(id); - continue; - } - if (!notepatMidiSubscriberMatches(sub, event)) continue; - try { - sub.ws.send(pack("notepat:midi", event, "midi-relay")); - } catch (err) { - error("๐ŸŽน Failed to fan out notepat midi event:", err); - } - } - // UDP fan-out. Same filter model, emitted on the geckos channel. M4L - // notepat-remote devices care about this path for sub-frame latency โ€” - // the WS path is ~5-15 ms slower end-to-end on a typical home network. - for (const [id, sub] of notepatMidiUdpSubscribers) { - if (!sub?.channel || sub.channel.webrtcConnection?.state !== "open") { - notepatMidiUdpSubscribers.delete(id); - continue; - } - if (!notepatMidiSubscriberMatches(sub, event)) continue; - try { - sub.channel.emit("notepat:midi", event); - } catch (err) { - error("๐ŸŽน UDP fan-out failed:", err); - } - } -} - -function upsertNotepatMidiSource({ handle, machineId, piece, lastEvent, ts, address, port }) { - const cleanHandle = normalizeMidiHandle(handle); - const cleanMachineId = `${machineId || "unknown"}`.trim() || "unknown"; - const key = notepatMidiSourceKey(cleanHandle, cleanMachineId); - const previous = notepatMidiSources.get(key); - const next = { - handle: cleanHandle || null, - machineId: cleanMachineId, - piece: piece || "notepat", - lastSeen: ts || Date.now(), - lastEvent: lastEvent || previous?.lastEvent || null, - address: address || previous?.address || null, - port: port || previous?.port || null, - }; - - notepatMidiSources.set(key, next); - - if (!previous) { - log(`๐ŸŽน Notepat MIDI source online: ${next.handle ? "@" + next.handle : "@unknown"} ${next.machineId}`); - } - - if ( - !previous || - previous.handle !== next.handle || - previous.machineId !== next.machineId || - previous.piece !== next.piece - ) { - broadcastNotepatMidiSources(); - } - - return next; -} - -function compactProfileText(value) { - return `${value || ""}`.replace(/\s+/g, " ").trim(); -} - -function truncateProfileText(value, max = 100) { - const text = compactProfileText(value); - if (text.length <= max) return text; - return `${text.slice(0, Math.max(0, max - 3))}...`; -} - -function getProfilePresence(handleKey) { - if (!handleKey) return null; - const clientsForStatus = getClientStatus(); - const matched = clientsForStatus.find( - (client) => normalizeProfileHandle(client?.handle) === handleKey, - ); - - if (!matched) { - return { - online: false, - currentPiece: null, - worldPiece: null, - showing: null, - connections: { websocket: 0, udp: 0, total: 0 }, - pingMs: null, - lastSeenAt: profileLastSeen.get(handleKey) || null, - }; - } - - const now = Date.now(); - profileLastSeen.set(handleKey, now); - - const world = matched?.websocket?.worlds?.[0] || null; - - return { - online: true, - currentPiece: matched.location || null, - worldPiece: world?.piece || null, - showing: world?.showing || null, - connections: matched.connectionCount || { websocket: 0, udp: 0, total: 0 }, - pingMs: matched?.websocket?.ping || null, - lastSeenAt: now, - }; -} - -function sendProfileStream(ws, type, data) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - ws.send(JSON.stringify({ type, data, timestamp: Date.now() })); - } catch (err) { - error("๐Ÿ‘ค Failed to send profile stream event:", err); - } -} - -function broadcastProfileStream(handleKey, type, data) { - const subs = profileStreamClients.get(handleKey); - if (!subs || subs.size === 0) return; - - const stale = []; - subs.forEach((ws) => { - if (ws.readyState !== WebSocket.OPEN) { - stale.push(ws); - return; - } - sendProfileStream(ws, type, data); - }); - - stale.forEach((ws) => subs.delete(ws)); - if (subs.size === 0) profileStreamClients.delete(handleKey); -} - -function addProfileStreamClient(ws, handle) { - const handleKey = normalizeProfileHandle(handle); - if (!handleKey) return null; - - if (!profileStreamClients.has(handleKey)) { - profileStreamClients.set(handleKey, new Set()); - } - - profileStreamClients.get(handleKey).add(ws); - ws.profileHandleKey = handleKey; - - const presence = getProfilePresence(handleKey); - sendProfileStream(ws, "profile:snapshot", { - handle: handleKey, - presence, - }); - sendProfileStream(ws, "counts:update", { - handle: handleKey, - counts: { - online: presence?.online ? 1 : 0, - connections: presence?.connections?.total || 0, - }, - }); - - return handleKey; -} - -function removeProfileStreamClient(ws) { - const handleKey = ws?.profileHandleKey; - if (!handleKey) return; - - const subs = profileStreamClients.get(handleKey); - if (!subs) { - ws.profileHandleKey = null; - return; - } - - subs.delete(ws); - if (subs.size === 0) profileStreamClients.delete(handleKey); - ws.profileHandleKey = null; -} - -function emitProfilePresence(handle, reason = "update", changed = []) { - const handleKey = normalizeProfileHandle(handle); - if (!handleKey) return; - - const presence = getProfilePresence(handleKey); - broadcastProfileStream(handleKey, "presence:update", { - handle: handleKey, - reason, - changed, - presence, - }); - broadcastProfileStream(handleKey, "counts:update", { - handle: handleKey, - counts: { - online: presence?.online ? 1 : 0, - connections: presence?.connections?.total || 0, - }, - }); -} - -function emitProfileCountDelta(handle, delta = {}) { - const handleKey = normalizeProfileHandle(handle); - if (!handleKey) return; - if (!delta || typeof delta !== "object") return; - - const cleanDelta = {}; - for (const [key, value] of Object.entries(delta)) { - const amount = Number(value); - if (!Number.isFinite(amount) || amount === 0) continue; - cleanDelta[key] = amount; - } - if (Object.keys(cleanDelta).length === 0) return; - - broadcastProfileStream(handleKey, "counts:delta", { - handle: handleKey, - delta: cleanDelta, - }); -} - -function emitProfileActivity(handle, event = {}) { - const handleKey = normalizeProfileHandle(handle); - if (!handleKey) return; - - const label = truncateProfileText( - event.label || event.text || event.type || "event", - 120, - ); - if (!label) return; - - broadcastProfileStream(handleKey, "activity:append", { - handle: handleKey, - event: { - type: event.type || "event", - when: event.when || Date.now(), - label, - ref: event.ref || null, - piece: event.piece || null, - }, - }); -} - -function resolveProfileHandle(id, piece, fromMessage) { - return ( - normalizeProfileHandle(fromMessage) || - normalizeProfileHandle(clients?.[id]?.handle) || - normalizeProfileHandle(worldClients?.[piece]?.[id]?.handle) - ); -} - -chatManager.setActivityEmitter((payload = {}) => { - const handle = payload.handle; - if (payload.event) emitProfileActivity(handle, payload.event); - if (payload.countsDelta) emitProfileCountDelta(handle, payload.countsDelta); -}); - -// Broadcast status updates every 2 seconds -setInterval(() => { - if (statusClients.size > 0) { - const status = getFullStatus(); - statusClients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - try { - client.send(JSON.stringify({ type: 'status', data: status })); - } catch (err) { - error('๐Ÿ“Š Failed to send status update:', err); - } - } - }); - } -}, 2000); - -// Broadcast targeted profile heartbeat updates every 2 seconds -setInterval(() => { - if (profileStreamClients.size === 0) return; - - for (const handleKey of profileStreamClients.keys()) { - const presence = getProfilePresence(handleKey); - broadcastProfileStream(handleKey, "presence:update", { - handle: handleKey, - reason: "heartbeat", - changed: [], - presence, - }); - } -}, 2000); - -// ๐Ÿงš UDP Server (using Twilio ICE servers) -// #endregion udp - -// Note: This currently works off of a monolith via `udp.aesthetic.computer` -// as the ports are blocked on jamsocket. - -// geckos.io is imported at top and initialized before server.listen() - -io.onConnection((channel) => { - // Track this UDP channel - udpChannels[channel.id] = { - connectedAt: Date.now(), - state: channel.webrtcConnection.state, - user: null, - handle: null, - channel: channel, // Store reference for targeted sends - }; - - // Get IP address from channel - const udpIp = channel.userData?.address || channel.remoteAddress || null; - - log(`๐Ÿฉฐ UDP ${channel.id} connected from:`, udpIp || 'unknown'); - - // Initialize client record with IP - if (!clients[channel.id]) clients[channel.id] = { udp: true }; - if (udpIp) { - const cleanIp = udpIp.replace('::ffff:', ''); - clients[channel.id].ip = cleanIp; - - // Get geolocation for UDP client - const geo = geoip.lookup(cleanIp); - if (geo) { - clients[channel.id].geo = { - country: geo.country, - region: geo.region, - city: geo.city, - timezone: geo.timezone, - ll: geo.ll - }; - log(`๐ŸŒ UDP ${channel.id} geolocation:`, geo.city || geo.country); - } - } - - // Set a timeout to warn about missing identity - setTimeout(() => { - if (!clients[channel.id]?.user && !clients[channel.id]?.handle) { - log(`โš ๏ธ UDP ${channel.id} has been connected for 10s but hasn't sent identity message`); - } - }, 10000); - - // Handle identity message - channel.on("udp:identity", (data) => { - try { - const identity = JSON.parse(data); - log(`๐Ÿฉฐ UDP ${channel.id} sent identity:`, JSON.stringify(identity).substring(0, 100)); - - // Initialize client record if needed - if (!clients[channel.id]) clients[channel.id] = { udp: true }; - - // Extract user identity - if (identity.user?.sub) { - clients[channel.id].user = identity.user.sub; - log(`๐Ÿฉฐ UDP ${channel.id} user:`, identity.user.sub.substring(0, 20) + "..."); - } - - // Extract handle directly from identity message - if (identity.handle) { - clients[channel.id].handle = identity.handle; - log(`โœ… UDP ${channel.id} handle: "${identity.handle}"`); - // Resolve UDP channel for duel if this handle is in a duel - duelManager.resolveUdpChannel(identity.handle, channel.id); - // Resolve UDP channel for arena if this handle is in the arena - arenaManager.resolveUdpChannel(identity.handle, channel.id); - } - } catch (e) { - error(`๐Ÿฉฐ Failed to parse identity for ${channel.id}:`, e); - } - }); - - channel.onDisconnect(() => { - log(`๐Ÿฉฐ ${channel.id} got disconnected`); - delete udpChannels[channel.id]; - fairyThrottle.delete(channel.id); - notepatMidiUdpSubscribers.delete(channel.id); - - // Clean up client record if no longer connected via any protocol - if (clients[channel.id]) { - clients[channel.id].udp = false; - // If also not connected via WebSocket, delete the client record entirely - if (!connections[channel.id]) { - delete clients[channel.id]; - } - } - - channel.close(); - }); - - // ๐ŸŽน Notepat MIDI relay over UDP. Same subscribe/unsubscribe model as the - // WS path (cross-session, filter on handle/machineId or all:true). Events - // fan out via notepatMidiUdpSubscribers in broadcastNotepatMidiEvent. - channel.on("notepat:midi:subscribe", (data) => { - let filter = {}; - try { filter = typeof data === "string" ? JSON.parse(data) : (data || {}); } catch {} - // Optional: wrap in `{ filter: {...} }` or pass fields directly โ€” accept both. - if (filter.filter) filter = filter.filter; - notepatMidiUdpSubscribers.set(channel.id, { - channel, - all: filter.all === true, - handle: normalizeMidiHandle(filter.handle), - machineId: filter.machineId ? `${filter.machineId}`.trim() : "", - }); - try { channel.emit("notepat:midi:subscribed", { - all: filter.all === true, - handle: normalizeMidiHandle(filter.handle) || null, - machineId: filter.machineId ? `${filter.machineId}`.trim() : null, - }); } catch {} - log(`๐ŸŽน UDP ${channel.id} subscribed to notepat:midi (all=${filter.all === true})`); - }); - - channel.on("notepat:midi:unsubscribe", () => { - notepatMidiUdpSubscribers.delete(channel.id); - try { channel.emit("notepat:midi:unsubscribed", true); } catch {} - }); - - // ๐ŸŽน Browser-side notepat (notepat.com on web) publishes MIDI events - // via the geckos UDP channel for sub-frame latency. Mirror of the WS - // `notepat:midi:publish` handler โ€” same shape, same broadcaster. - const handleUdpNotepatPublish = (data, isHeartbeat) => { - let payload = {}; - try { payload = typeof data === "string" ? JSON.parse(data) : (data || {}); } catch {} - const now = Date.now(); - const source = upsertNotepatMidiSource({ - handle: payload.handle, - machineId: payload.machineId, - piece: payload.piece || "notepat", - lastEvent: isHeartbeat ? "heartbeat" : payload.event, - ts: now, - }); - if (isHeartbeat) return; - if (!source.handle && !source.machineId) return; - const rawNote = Number(payload.note); - const rawVelocity = Number(payload.velocity); - const rawChannel = Number(payload.channel); - if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) return; - let event = payload.event === "note_off" ? "note_off" : "note_on"; - const note = Math.max(0, Math.min(127, Math.round(rawNote))); - const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); - const channel2 = Math.max(0, Math.min(15, Math.round(rawChannel))); - if (event === "note_on" && velocity === 0) event = "note_off"; - broadcastNotepatMidiEvent({ - type: "notepat:midi", - event, - note, - velocity, - channel: channel2, - handle: source.handle, - machineId: source.machineId, - piece: source.piece || "notepat", - ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, - }); - }; - channel.on("notepat:midi", (data) => handleUdpNotepatPublish(data, false)); - channel.on("notepat:midi:heartbeat", (data) => handleUdpNotepatPublish(data, true)); - - // ๐Ÿ’Ž TODO: Make these channel names programmable somehow? 24.12.08.04.12 - - channel.on("tv", (data) => { - if (channel.webrtcConnection.state === "open") { - try { - channel.room.emit("tv", data); - } catch (err) { - console.warn("Broadcast error:", err); - } - } else { - console.log(channel.webrtcConnection.state); - } - }); - - // Just for testing via the aesthetic `udp` piece for now. - channel.on("fairy:point", (data) => { - // See docs here: https://github.com/geckosio/geckos.io#reliable-messages - // TODO: - [] Learn about the differences between channels and rooms. - - // log(`๐Ÿฉฐ fairy:point - ${data}`); - if (channel.webrtcConnection.state === "open") { - try { - channel.broadcast.emit("fairy:point", data); - // ^ emit the to all channels in the same room except the sender - - // Bridge to raw UDP clients (native bare-metal) - try { - const parsed = typeof data === "string" ? JSON.parse(data) : data; - const x = parseFloat(parsed.x) || 0; - const y = parseFloat(parsed.y) || 0; - const handle = parsed.handle || ""; - const hlen = Buffer.byteLength(handle, "utf8"); - const pkt = Buffer.alloc(10 + hlen); - pkt[0] = 0x02; // fairy broadcast - pkt.writeFloatLE(x, 1); - pkt.writeFloatLE(y, 5); - pkt[9] = hlen; - pkt.write(handle, 10, "utf8"); - for (const [, client] of udpClients) { - udpRelay.send(pkt, client.port, client.address); - } - } catch (e) { /* ignore bridge errors */ } - - // Publish to Redis for silo firehose visualization (throttled ~10Hz) - const now = Date.now(); - const last = fairyThrottle.get(channel.id) || 0; - if (now - last >= FAIRY_THROTTLE_MS) { - fairyThrottle.set(channel.id, now); - pub.publish("fairy:point", data).catch(() => {}); - } - } catch (err) { - console.warn("Broadcast error:", err); - } - } else { - console.log(channel.webrtcConnection.state); - } - }); - - // ๐ŸŽฎ 1v1 FPS game position updates over UDP (low latency) - channel.on("1v1:move", (data) => { - if (channel.webrtcConnection.state === "open") { - try { - // Log occasionally for production debugging (1 in 100) - if (Math.random() < 0.01) { - const parsed = typeof data === 'string' ? JSON.parse(data) : data; - log(`๐Ÿฉฐ UDP 1v1:move: ${parsed?.handle || channel.id} broadcasting`); - } - // Broadcast position to all other players except sender - channel.broadcast.emit("1v1:move", data); - } catch (err) { - console.warn("1v1:move broadcast error:", err); - } - } - }); - - // ๐ŸŽพ Squash game position updates over UDP (low latency) - channel.on("squash:move", (data) => { - if (channel.webrtcConnection.state === "open") { - try { - channel.broadcast.emit("squash:move", data); - } catch (err) { - console.warn("squash:move broadcast error:", err); - } - } - }); - - // ๐ŸŽฏ Duel input over UDP (server-authoritative โ€” NOT relayed, fed to DuelManager) - channel.on("duel:input", (data) => { - if (channel.webrtcConnection.state === "open") { - try { - const parsed = typeof data === "string" ? JSON.parse(data) : data; - // Resolve handle from channel identity OR from message payload - const handle = clients[channel.id]?.handle || parsed.handle; - if (handle) { - duelManager.receiveInput(handle, parsed); - // Also resolve UDP channel if not yet linked - if (!clients[channel.id]?.handle && parsed.handle) { - duelManager.resolveUdpChannel(parsed.handle, channel.id); - } - } - } catch (err) { - console.warn("duel:input error:", err); - } - } - }); - - // ๐ŸŸ๏ธ Arena usercmd over UDP (fast path; WS is the fallback) - channel.on("arena:cmd", (data) => { - if (channel.webrtcConnection.state !== "open") return; - try { - const parsed = typeof data === "string" ? JSON.parse(data) : data; - const handle = clients[channel.id]?.handle || parsed.handle; - if (!handle) return; - arenaManager.receiveCmd(handle, parsed); - if (!clients[channel.id]?.handle && parsed.handle) { - arenaManager.resolveUdpChannel(parsed.handle, channel.id); - } - } catch (err) { - console.warn("arena:cmd error:", err); - } - }); - - // ๐ŸŽš๏ธ Slide mode: real-time code value updates via UDP (lowest latency) - channel.on("slide:code", (data) => { - if (channel.webrtcConnection.state === "open") { - try { - // Broadcast to all including sender (room.emit) for sync - channel.room.emit("slide:code", data); - } catch (err) { - console.warn("slide:code broadcast error:", err); - } - } - }); - - // ๐Ÿ”Š Audio: real-time audio analysis data via UDP (lowest latency) - channel.on("udp:audio", (data) => { - if (channel.webrtcConnection.state === "open") { - try { - channel.room.emit("udp:audio", data); - } catch (err) { - console.warn("udp:audio broadcast error:", err); - } - } - }); -}); - -// #endregion - -// --------------------------------------------------------------------------- -// ๐Ÿงš Raw UDP fairy relay (port 10010) โ€” for native bare-metal clients -// Binary packet format: -// [1 byte type] [4 float x LE] [4 float y LE] [1 handle_len] [N handle] -// Type 0x01 = clientโ†’server, 0x02 = serverโ†’client broadcast -// --------------------------------------------------------------------------- -const UDP_FAIRY_PORT = 10010; - -function handleNotepatMidiUdpPacket(payload, rinfo) { - if (!payload || (payload.type !== "notepat:midi" && payload.type !== "notepat:midi:heartbeat")) { - return false; - } - - const now = Date.now(); - const source = upsertNotepatMidiSource({ - handle: payload.handle, - machineId: payload.machineId, - piece: payload.piece || "notepat", - lastEvent: payload.type === "notepat:midi" ? payload.event : "heartbeat", - ts: now, - address: rinfo.address, - port: rinfo.port, - }); - - if (!source.handle && !source.machineId) { - return true; - } - - if (payload.type === "notepat:midi:heartbeat") { - return true; - } - - const rawNote = Number(payload.note); - const rawVelocity = Number(payload.velocity); - const rawChannel = Number(payload.channel); - if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) { - log("๐ŸŽน Invalid notepat midi UDP payload:", payload); - return true; - } - - let event = payload.event === "note_off" ? "note_off" : "note_on"; - const note = Math.max(0, Math.min(127, Math.round(rawNote))); - const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); - const channel = Math.max(0, Math.min(15, Math.round(rawChannel))); - if (event === "note_on" && velocity === 0) event = "note_off"; - - broadcastNotepatMidiEvent({ - type: "notepat:midi", - event, - note, - velocity, - channel, - handle: source.handle, - machineId: source.machineId, - piece: source.piece || "notepat", - ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, - }); - - return true; -} - -function pruneNotepatMidiSources() { - const now = Date.now(); - let changed = false; - - for (const [key, source] of notepatMidiSources) { - if (now - (source.lastSeen || 0) > UDP_MIDI_SOURCE_TTL_MS) { - notepatMidiSources.delete(key); - changed = true; - } - } - - if (changed) broadcastNotepatMidiSources(); -} - -udpRelay.on("message", (msg, rinfo) => { - if (msg.length > 0 && msg[0] === 0x01 && msg.length >= 10) { - const key = `${rinfo.address}:${rinfo.port}`; - const x = msg.readFloatLE(1); - const y = msg.readFloatLE(5); - const hlen = msg[9]; - const handle = msg.slice(10, 10 + hlen).toString("utf8"); - - udpClients.set(key, { address: rinfo.address, port: rinfo.port, handle, lastSeen: Date.now() }); - - // Build broadcast packet (type 0x02) - const bcast = Buffer.alloc(msg.length); - msg.copy(bcast); - bcast[0] = 0x02; - - // Broadcast to all other UDP clients - for (const [k, client] of udpClients) { - if (k !== key) { - udpRelay.send(bcast, client.port, client.address); - } - } - - // Also broadcast to Geckos.io WebRTC clients as fairy:point - const fairyData = JSON.stringify({ x, y, handle }); - try { - // Emit to all geckos channels - io.room().emit("fairy:point", fairyData); - } catch (e) { /* ignore */ } - - // Publish to Redis for silo firehose (throttled) - const now = Date.now(); - const lastFairy = fairyThrottle.get(key) || 0; - if (now - lastFairy >= FAIRY_THROTTLE_MS) { - fairyThrottle.set(key, now); - pub.publish("fairy:point", fairyData).catch(() => {}); - } - return; - } - - if (msg.length > 0 && msg[0] === 0x7b) { - try { - const payload = JSON.parse(msg.toString("utf8")); - if (handleNotepatMidiUdpPacket(payload, rinfo)) return; - } catch (err) { - log("๐ŸŽน Failed to parse UDP JSON packet:", err?.message || err); - } - } -}); - -// Clean up stale UDP clients every 30s -setInterval(() => { - const now = Date.now(); - for (const [key, client] of udpClients) { - if (now - client.lastSeen > 30000) udpClients.delete(key); - } - pruneNotepatMidiSources(); -}, 30000); - -udpRelay.bind(UDP_FAIRY_PORT, () => { - console.log(`๐Ÿงš Raw UDP fairy relay listening on port ${UDP_FAIRY_PORT}`); -}); - -// Bridge: forward Geckos fairy:point to UDP clients -// (patched into the existing fairy:point handler above via io.room().emit) -// When a Geckos client sends fairy:point, also relay to UDP clients: -const origFairyHandler = true; // marker โ€” actual bridging done in channel.on("fairy:point") below - -// #endregion UDP fairy relay - -// ๐Ÿšง File Watching in Local Development Mode -// File watching uses: https://github.com/paulmillr/chokidar -if (dev) { - // 1. Watch for local file changes in pieces. - chokidar - .watch("../system/public/aesthetic.computer/disks") - .on("all", (event, path) => { - if (event === "change") { - const piece = path - .split("/") - .pop() - .replace(/\.mjs|\.lisp$/, ""); - everyone(pack("reload", { piece: piece || "*" }, "local")); - } - }); // 2. Watch base system files. - chokidar - .watch([ - "../system/netlify/functions", - "../system/public/privacy-policy.html", - "../system/public/aesthetic-direct.html", - "../system/public/aesthetic.computer/lib", - "../system/public/aesthetic.computer/systems", // This doesn't need a full reload / could just reload the disk module? - "../system/public/aesthetic.computer/boot.mjs", - "../system/public/aesthetic.computer/bios.mjs", - "../system/public/aesthetic.computer/style.css", - "../system/public/kidlisp.com", - "../system/public/l5.aesthetic.computer", - "../system/public/gift.aesthetic.computer", - "../system/public/give.aesthetic.computer", - "../system/public/news.aesthetic.computer", - ]) - .on("all", (event, path) => { - if (event === "change") - everyone(pack("reload", { piece: "*refresh*" }, "local")); - }); - - // 2b. Watch prompt files separately (piece reload instead of full refresh) - chokidar - .watch("../system/public/aesthetic.computer/prompts") - .on("all", (event, path) => { - if (event === "change") { - const filename = path.split("/").pop(); - console.log(`๐ŸŽจ Prompt file changed: ${filename}`); - everyone(pack("reload", { piece: "*piece-reload*" }, "local")); - } - }); - - // 3. Watch vscode extension - chokidar.watch("../vscode-extension/out").on("all", (event, path) => { - if (event === "change") - everyone(pack("vscode-extension:reload", { reload: true }, "local")); - }); -} - -/* -if (termkit) { - term = termkit.terminal; - - const doc = term.createDocument({ - palette: new termkit.Palette(), - }); - - // Create left (log) and right (client list) columns - const leftColumn = new termkit.Container({ - parent: doc, - x: 0, - width: "70%", - height: "100%", - }); - - const rightColumn = new termkit.Container({ - parent: doc, - x: "70%", - width: "30%", - height: "100%", - }); - - term.grabInput(); - - console.log("grabbed input"); - - term.on("key", function (name, matches, data) { - console.log("'key' event:", name); - - // Detect CTRL-C and exit 'manually' - if (name === "CTRL_C") { - process.exit(); - } - }); - - term.on("mouse", function (name, data) { - console.log("'mouse' event:", name, data); - }); - - // Log box in the left column - const logBox = new termkit.TextBox({ - parent: leftColumn, - content: "Your logs will appear here...\n", - scrollable: true, - vScrollBar: true, - x: 0, - y: 0, - width: "100%", - height: "100%", - mouse: true, // to allow mouse interactions if needed - }); - - // Static list box in the right column - const clientList = new termkit.TextBox({ - parent: rightColumn, - content: "Client List:\n", - x: 0, - y: 0, - width: "100%", - height: "100%", - }); - - // Example functions to update contents - function addLog(message) { - logBox.setContent(logBox.getContent() + message + "\n"); - // logBox.scrollBottom(); - doc.draw(); - } - - function updateClientList(clients) { - clientList.setContent("Client List:\n" + clients.join("\n")); - doc.draw(); - } - - // Example usage - addLog("Server started..."); - updateClientList(["Client1", "Client2"]); - - // Handle input for graceful exit - // term.grabInput(); - // term.on("key", (key) => { - // if (key === "CTRL_C") { - // process.exit(); - // } - // }); - - // doc.draw(); -} -*/ - -function log() { - console.log(...arguments); -} - -function error() { - console.error(...arguments); -} +// Session Server, 23.12.04.14.57 +// Represents a "room" or user or "client" backend +// which at the moment is run once for every "piece" +// that requests it. + +/* #region todo + + Now + - [-] Fix live reloading of in-production udp. + + Done + - [c] `code.channel` should return a promise, and wait for a + `code-channel:subbed`. + event here? This way users get better confirmation if the socket + doesn't go through or if there is a server issue. 23.07.04.18.01 + (Might not actually be that necessary.) + - [x] Add `obscenity` filter. + - [x] Conditional redis sub to dev updates. (Will save bandwidth if extension + gets lots of use, also would be more secure.) + - [x] Secure the "code" path to require a special string. + - [x] Secure the "reload" path (must be in dev mode, sorta okay) + - [c] Speed up developer reload by using redis pub/sub. + - [x] Send a signal to everyone once a user leaves. + - [x] Get "developer" live reloading working again. + - [x] Add sockets back. + - [x] Make a "local" option. + - [x] Read through: https://redis.io/docs/data-types +#endregion */ + +// Add redis pub/sub here... + +import Fastify from "fastify"; +import geckos from "@geckos.io/server"; +import geoip from "geoip-lite"; +import { WebSocket, WebSocketServer } from "ws"; +import ip from "ip"; +import chokidar from "chokidar"; +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import dotenv from "dotenv"; +import dgram from "dgram"; +dotenv.config(); + +// Module streaming - path to public directory +const PUBLIC_DIR = path.resolve(process.cwd(), "../system/public/aesthetic.computer"); + +// Module hash cache (invalidated on file change) +const moduleHashes = new Map(); // path -> { hash, content, mtime } + +// Compute hash for a module file +function getModuleHash(modulePath) { + const fullPath = path.join(PUBLIC_DIR, modulePath); + try { + const stats = fs.statSync(fullPath); + const cached = moduleHashes.get(modulePath); + + // Return cached if mtime matches + if (cached && cached.mtime === stats.mtimeMs) { + return cached; + } + + // Read and hash + const content = fs.readFileSync(fullPath, "utf8"); + const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 16); + const entry = { hash, content, mtime: stats.mtimeMs }; + moduleHashes.set(modulePath, entry); + return entry; + } catch (err) { + return null; + } +} + +// Fairy:point throttle (for silo firehose visualization) +const fairyThrottle = new Map(); // channelId -> last publish timestamp +const FAIRY_THROTTLE_MS = 100; // 10Hz max per connection + +// Raw UDP fairy relay (for native bare-metal clients) +const udpRelay = dgram.createSocket("udp4"); +const udpClients = new Map(); // key "ip:port" โ†’ { address, port, handle, lastSeen } +const UDP_MIDI_SOURCE_TTL_MS = 20000; +const notepatMidiSources = new Map(); // key "@handle:machine" -> source metadata +const notepatMidiSubscribers = new Map(); // connection id -> { ws, all, handle, machineId } +// UDP-side subscribers for notepat:midi fan-out over geckos.io. The WS map +// above handles reliable subscription handshakes; this map mirrors the same +// filter model against geckos channels so we can emit events twice (once +// reliably over WS, once low-latency over UDP) to consumers that opened both. +const notepatMidiUdpSubscribers = new Map(); // channel id -> { channel, all, handle, machineId } + +// Error logging ring buffer (for dashboard display) +const errorLog = []; +const MAX_ERRORS = 50; +const ERROR_RETENTION_MS = 60 * 60 * 1000; // 1 hour + +function logError(level, message) { + const entry = { + level, + message: typeof message === 'string' ? message : JSON.stringify(message), + timestamp: new Date().toISOString() + }; + errorLog.push(entry); + if (errorLog.length > MAX_ERRORS) errorLog.shift(); +} + +// Capture uncaught errors +process.on('uncaughtException', (err) => { + logError('error', `Uncaught: ${err.message}`); + console.error('Uncaught Exception:', err); +}); + +process.on('unhandledRejection', (reason, promise) => { + logError('error', `Unhandled Rejection: ${reason}`); + console.error('Unhandled Rejection:', reason); +}); + +import { exec } from "child_process"; + +// FCM (Firebase Cloud Messaging) +import { initializeApp, cert } from "firebase-admin/app"; // Firebase notifications. +//import serviceAccount from "./aesthetic-computer-firebase-adminsdk-79w8j-5b5cdfced8.json" assert { type: "json" }; +import { getMessaging } from "firebase-admin/messaging"; + +let serviceAccount; +try { + const response = await fetch(process.env.GCM_FIREBASE_CONFIG_URL); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + serviceAccount = await response.json(); +} catch (error) { + console.error("Error fetching service account:", error); + // Handle the error as needed +} + +initializeApp( + { credential: cert(serviceAccount) }, //, + //"aesthetic" + ~~performance.now(), +); + +// Initialize ChatManager for multi-instance chat support +const chatManager = new ChatManager({ dev: process.env.NODE_ENV === "development" }); +await chatManager.init(); + +// Graceful shutdown โ€” persist in-memory chat messages before exit +let shuttingDown = false; +async function gracefulShutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`\n${signal} received, persisting chat messages...`); + try { + await chatManager.shutdown(); + } catch (err) { + console.error("Shutdown error:", err); + } + process.exit(0); +} +process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); +process.on("SIGINT", () => gracefulShutdown("SIGINT")); + +// Helper function to get handles of users currently on a specific piece +// Used by chatManager to determine who's actually viewing the chat piece +function getHandlesOnPiece(pieceName) { + const handles = []; + for (const [id, client] of Object.entries(clients)) { + if (client.location === pieceName && client.handle) { + handles.push(client.handle); + } + } + return [...new Set(handles)]; // Remove duplicates +} + +// Expose the function to chatManager +chatManager.setPresenceResolver(getHandlesOnPiece); + +// ๐ŸŽฏ Duel Manager โ€” server-authoritative game for dumduel piece +const duelManager = new DuelManager(); +// ๐ŸŸ๏ธ Arena Manager โ€” Q3-style server-authoritative multiplayer for arena piece +const arenaManager = new ArenaManager(); + +import { filter } from "./filter.mjs"; // Profanity filtering. +import { ChatManager } from "./chat-manager.mjs"; // Multi-instance chat support. +import { DuelManager } from "./duel-manager.mjs"; // Server-authoritative duel game. +import { ArenaManager } from "./arena-manager.mjs"; // Server-authoritative arena game. + +// *** AC Machines โ€” remote device monitoring *** +// Devices connect via /machines?role=device&machineId=X&token=Y +// Viewers connect via /machines?role=viewer&token=Y +import { MongoClient } from "mongodb"; + +const machinesDevices = new Map(); // machineId โ†’ { ws, user, handle, machineId, info, lastHeartbeat } +const machinesViewers = new Map(); // userSub โ†’ Set +let machinesDb = null; + +async function getMachinesDb() { + if (machinesDb) return machinesDb; + const connStr = process.env.MONGODB_CONNECTION_STRING; + if (!connStr) return null; + try { + const client = new MongoClient(connStr); + await client.connect(); + machinesDb = client.db(process.env.MONGODB_NAME || "aesthetic"); + return machinesDb; + } catch (e) { + error("[machines] MongoDB connect error:", e.message); + return null; + } +} + +let machineTokenSecret = null; +let machineTokenSecretAt = 0; +const MACHINE_SECRET_TTL = 5 * 60 * 1000; // refresh from DB every 5 min + +async function loadMachineTokenSecret() { + const now = Date.now(); + if (machineTokenSecret && now - machineTokenSecretAt < MACHINE_SECRET_TTL) { + return machineTokenSecret; + } + try { + const db = await getMachinesDb(); + if (!db) return machineTokenSecret; + const doc = await db.collection("secrets").findOne({ _id: "machine-token" }); + if (doc?.secret) { + machineTokenSecret = doc.secret; + machineTokenSecretAt = now; + } + } catch (e) { + error("[machines] Failed to load machine-token secret:", e.message); + } + return machineTokenSecret; +} + +async function verifyMachineToken(token) { + if (!token) return null; + const secret = await loadMachineTokenSecret(); + if (!secret) return null; + try { + const [payloadB64, sigB64] = token.split("."); + if (!payloadB64 || !sigB64) return null; + const expectedSig = crypto + .createHmac("sha256", secret) + .update(payloadB64) + .digest("base64url"); + if (sigB64 !== expectedSig) return null; + return JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + } catch { + return null; + } +} + +// Verify an AC auth token (Bearer token from authorize()) by calling Auth0 userinfo +async function verifyACToken(token) { + if (!token) return null; + try { + const res = await fetch("https://aesthetic.us.auth0.com/userinfo", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + return await res.json(); // { sub, nickname, name, ... } + } catch { + return null; + } +} + +function broadcastToMachineViewers(userSub, msg) { + const viewers = machinesViewers.get(userSub); + if (!viewers) return; + const data = JSON.stringify(msg); + for (const v of viewers) { + if (v.readyState === WebSocket.OPEN) v.send(data); + } +} + +async function upsertMachine(userSub, machineId, info) { + const db = await getMachinesDb(); + if (!db) return; + const col = db.collection("ac-machines"); + const now = new Date(); + await col.updateOne( + { user: userSub, machineId }, + { + $set: { + user: userSub, + machineId, + ...info, + status: "online", + linked: true, + lastSeen: now, + updatedAt: now, + }, + $setOnInsert: { createdAt: now, bootCount: 0 }, + $inc: { bootCount: 1 }, + }, + { upsert: true }, + ); +} + +async function updateMachineHeartbeat(userSub, machineId, uptime, currentPiece) { + const db = await getMachinesDb(); + if (!db) return; + await db.collection("ac-machines").updateOne( + { user: userSub, machineId }, + { $set: { lastSeen: new Date(), uptime, currentPiece, status: "online" } }, + ); +} + +async function insertMachineLog(userSub, machineId, msg) { + const db = await getMachinesDb(); + if (!db) return; + await db.collection("ac-machine-logs").insertOne({ + machineId, + user: userSub, + type: msg.logType || "log", + level: msg.level || "info", + message: msg.message, + data: msg.data || null, + crashInfo: msg.crashInfo || null, + when: msg.when ? new Date(msg.when) : new Date(), + receivedAt: new Date(), + }); +} + +async function setMachineOffline(userSub, machineId) { + const db = await getMachinesDb(); + if (!db) return; + await db.collection("ac-machines").updateOne( + { user: userSub, machineId }, + { $set: { status: "offline", updatedAt: new Date() } }, + ); +} + +// *** SockLogs - Remote console log forwarding from devices *** +// Devices with ?socklogs param send logs via WebSocket +// Viewers (CLI or web) can subscribe to see device logs in real-time +const socklogsDevices = new Map(); // deviceId -> { ws, lastLog, logCount } +const socklogsViewers = new Set(); // Set of viewer WebSockets + +function socklogsBroadcast(deviceId, logEntry) { + const message = JSON.stringify({ + type: 'log', + deviceId, + ...logEntry, + serverTime: Date.now() + }); + for (const viewer of socklogsViewers) { + if (viewer.readyState === WebSocket.OPEN) { + viewer.send(message); + } + } +} + +function socklogsStatus() { + return { + devices: Array.from(socklogsDevices.entries()).map(([id, info]) => ({ + deviceId: id, + logCount: info.logCount, + lastLog: info.lastLog, + connectedAt: info.connectedAt + })), + viewerCount: socklogsViewers.size + }; +} + +import { createClient } from "redis"; +const redisConnectionString = process.env.REDIS_CONNECTION_STRING; +const dev = process.env.NODE_ENV === "development"; + +// Dev log file for remote debugging +const DEV_LOG_FILE = path.join(process.cwd(), "../system/public/aesthetic.computer/dev-logs.txt"); + +const { keys } = Object; +let fastify; //, termkit, term; + +if (dev) { + // Load local ssl certs in development mode. + fastify = Fastify({ + https: { + // allowHTTP1: true, + key: fs.readFileSync("../ssl-dev/localhost-key.pem"), + cert: fs.readFileSync("../ssl-dev/localhost.pem"), + }, + logger: true, + }); + + // Import the `terminal-kit` library if dev is true. + // try { + // termkit = (await import("terminal-kit")).default; + // } catch (err) { + // error("Failed to load terminal-kit", error); + // } +} else { + fastify = Fastify({ logger: true }); // Still log in production. No reason not to? +} + +// Insert `cors` headers as needed. 23.12.19.16.31 +// TODO: Is this even necessary? +fastify.options("*", async (req, reply) => { + const allowedOrigins = [ + "https://aesthetic.local:8888", + "https://aesthetic.computer", + "https://notepat.com", + ]; + + const origin = req.headers.origin; + log("โœˆ๏ธ Preflight origin:", origin); + // Check if the incoming origin is allowed + if (allowedOrigins.includes(origin)) { + reply.header("Access-Control-Allow-Origin", origin); + } + reply.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); + reply.send(); +}); + +const server = fastify.server; + +const DEV_LOG_DIR = "/tmp/dev-logs/"; +const deviceLogFiles = new Map(); // Track which devices have log files + +// Ensure log directory exists +if (dev) { + try { + fs.mkdirSync(DEV_LOG_DIR, { recursive: true }); + } catch (error) { + console.error("Failed to create dev log directory:", error); + } +} + +const info = { + port: process.env.PORT, // 8889 in development via `package.json` + name: process.env.SESSION_BACKEND_ID, + service: process.env.JAMSOCKET_SERVICE, +}; + +const codeChannels = {}; // Used to filter `code` updates from redis to +// clients who explicitly have the channel set. +const codeChannelState = {}; // Store last code sent to each channel for late joiners + +// DAW channel for M4L device โ†” IDE communication +const dawDevices = new Set(); // Connection IDs of /device instances +const dawIDEs = new Set(); // Connection IDs of IDE instances in Ableton mode + +// Unified client tracking: each client has handle, user, location, and connection types +const clients = {}; // Map of connection ID to { handle, user, location, websocket: true/false, udp: true/false } + +// Device naming for local dev (persisted to file) +const DEVICE_NAMES_FILE = path.join(process.cwd(), "../.device-names.json"); +let deviceNames = {}; // Map of IP -> { name, group } +function loadDeviceNames() { + try { + if (fs.existsSync(DEVICE_NAMES_FILE)) { + deviceNames = JSON.parse(fs.readFileSync(DEVICE_NAMES_FILE, 'utf8')); + log("๐Ÿ“ฑ Loaded device names:", Object.keys(deviceNames).length); + } + } catch (e) { + log("๐Ÿ“ฑ Could not load device names:", e.message); + } +} +function saveDeviceNames() { + try { + fs.writeFileSync(DEVICE_NAMES_FILE, JSON.stringify(deviceNames, null, 2)); + } catch (e) { + log("๐Ÿ“ฑ Could not save device names:", e.message); + } +} +if (dev) loadDeviceNames(); + +// Get the dev host machine name +import os from "os"; +const DEV_HOST_NAME = os.hostname(); +const DEV_LAN_IP = (() => { + // First, try to read from /tmp/host-lan-ip (written by entry.fish in devcontainer) + try { + const hostIpFile = '/tmp/host-lan-ip'; + if (fs.existsSync(hostIpFile)) { + const ip = fs.readFileSync(hostIpFile, 'utf-8').trim(); + if (ip && ip.match(/^\d+\.\d+\.\d+\.\d+$/)) { + console.log(`๐Ÿ–ฅ๏ธ Using host LAN IP from ${hostIpFile}: ${ip}`); + return ip; + } + } + } catch (e) { /* ignore */ } + + // Fallback: try to detect from network interfaces + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === 'IPv4' && !iface.internal && iface.address.startsWith('192.168.')) { + return iface.address; + } + } + } + return null; +})(); +console.log(`๐Ÿ–ฅ๏ธ Dev host: ${DEV_HOST_NAME}, LAN IP: ${DEV_LAN_IP || 'N/A'}`); + +// Helper: Assign device letters (A, B, C...) based on connection order +function getDeviceLetter(connectionId) { + // Get sorted list of connection IDs + const sortedIds = Object.keys(connections) + .map(id => parseInt(id)) + .sort((a, b) => a - b); + const index = sortedIds.indexOf(parseInt(connectionId)); + if (index === -1) return '?'; + // A=65, B=66, etc. Wrap around after Z + return String.fromCharCode(65 + (index % 26)); +} + +// Helper: Find connections by ID, IP, handle, or device letter +function targetClients(target) { + if (target === 'all') { + return Object.entries(connections) + .filter(([id, ws]) => ws?.readyState === WebSocket.OPEN) + .map(([id, ws]) => ({ id: parseInt(id), ws })); + } + + const results = []; + for (const [id, ws] of Object.entries(connections)) { + const client = clients[id]; + const cleanTarget = target.replace('@', ''); + const cleanIp = client?.ip?.replace('::ffff:', ''); + const deviceLetter = getDeviceLetter(id); + + if ( + String(id) === String(target) || + cleanIp === target || + client?.handle === `@${cleanTarget}` || + client?.handle === cleanTarget || + deviceNames[cleanIp]?.name?.toLowerCase() === target.toLowerCase() || + deviceLetter.toLowerCase() === target.toLowerCase() // Match by letter (A, B, C...) + ) { + if (ws?.readyState === WebSocket.OPEN) { + results.push({ id: parseInt(id), ws }); + } + } + } + return results; +} + +// *** Start up two `redis` clients. (One for subscribing, and for publishing) +const redisEnabled = !!redisConnectionString; +const sub = redisEnabled + ? (!dev ? createClient({ url: redisConnectionString }) : createClient()) + : null; +if (sub) sub.on("error", (err) => { + log("๐Ÿ”ด Redis subscriber client error!", err); + logError('error', `Redis sub: ${err.message}`); +}); + +const pub = redisEnabled + ? (!dev ? createClient({ url: redisConnectionString }) : createClient()) + : null; +if (pub) pub.on("error", (err) => { + log("๐Ÿ”ด Redis publisher client error!", err); + logError('error', `Redis pub: ${err.message}`); +}); + +try { + if (sub && pub) { + await sub.connect(); + await pub.connect(); + + await sub.subscribe("code", (message) => { + const parsed = JSON.parse(message); + if (codeChannels[parsed.codeChannel]) { + const msg = pack("code", message, "development"); + subscribers(codeChannels[parsed.codeChannel], msg); + } + }); + + await sub.subscribe("scream", (message) => { + everyone(pack("scream", message, "screamer")); // Socket back to everyone. + }); + } else { + log("โš ๏ธ Redis disabled โ€” code/scream channels unavailable"); + } +} catch (err) { + error("๐Ÿ”ด Could not connect to `redis` instance."); +} + +const secret = process.env.GITHUB_WEBHOOK_SECRET; + +fastify.post("/update", (request, reply) => { + const signature = request.headers["x-hub-signature"]; + const hash = + "sha1=" + + crypto + .createHmac("sha1", secret) + .update(JSON.stringify(request.body)) + .digest("hex"); + + if (hash !== signature) { + reply.status(401).send({ error: "Invalid signature" }); + return; + } + + // log("Path:", process.env.PATH); + + // Restart service in production. + // exec( + // "cd /home/aesthetic-computer/session-server; pm2 stop all; git pull; npm install; pm2 start all", + // (err, stdout, stderr) => { + // if (err) { + // error(`exec error: ${error}`); + // return; + // } + // log(`stdout: ${stdout}`); + // error(`stderr: ${stderr}`); + // }, + // ); + + reply.send({ status: "ok" }); +}); + +// *** Robots.txt - prevent crawling *** +fastify.get("/robots.txt", async (req, reply) => { + reply.type("text/plain"); + return "User-agent: *\nDisallow: /"; +}); + +// *** Module HTTP endpoint - serve modules directly (bypasses Netlify proxy) *** +// Used by boot.mjs on localhost when the main proxy is flaky +fastify.get("/module/*", async (req, reply) => { + const modulePath = req.params["*"]; + const moduleData = getModuleHash(modulePath); + + if (moduleData) { + reply + .header("Content-Type", "application/javascript; charset=utf-8") + .header("Access-Control-Allow-Origin", "*") + .header("Cache-Control", "no-cache") + .send(moduleData.content); + } else { + reply.status(404).send({ error: "Module not found", path: modulePath }); + } +}); + +// *** Build Stream - pipe terminal output to WebSocket clients *** +// Available in both dev and production for build progress streaming +fastify.post("/build-stream", async (req) => { + const line = typeof req.body === 'string' ? req.body : req.body.line || ''; + everyone(pack("build:log", { line, timestamp: Date.now() })); + return { status: "ok" }; +}); + +fastify.post("/build-status", async (req) => { + everyone(pack("build:status", { ...req.body, timestamp: Date.now() })); + return { status: "ok" }; +}); + +// *** FF1 Art Computer Proxy *** +// Proxies displayPlaylist commands to FF1 via direct connection or cloud relay +const FF1_RELAY_URL = "https://artwork-info.feral-file.workers.dev/api/cast"; + +// Load FF1 config from machines.json +function getFF1Config() { + try { + const machinesPath = path.resolve(process.cwd(), "../aesthetic-computer-vault/machines.json"); + const machines = JSON.parse(fs.readFileSync(machinesPath, "utf8")); + return machines.machines?.["ff1-dvveklza"] || null; + } catch (e) { + log("โš ๏ธ Could not load FF1 config from machines.json:", e.message); + return null; + } +} + +// Execute FF1 cast via SSH through MacBook (for devcontainer) +async function castViaSSH(ff1Config, payload) { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + const ip = ff1Config.ip; + const port = ff1Config.port || 1111; + const payloadJson = JSON.stringify(payload).replace(/'/g, "'\\''"); // Escape for shell + + // SSH through MacBook to reach FF1 on local network + 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}'"`; + + log(`๐Ÿ“ก FF1 cast via SSH: http://${ip}:${port}/api/cast`); + const { stdout, stderr } = await execAsync(sshCmd, { timeout: 15000 }); + + if (stderr && !stdout) { + throw new Error(stderr); + } + + try { + return JSON.parse(stdout); + } catch { + return { raw: stdout }; + } +} + +fastify.post("/ff1/cast", async (req, reply) => { + reply.header("Access-Control-Allow-Origin", "*"); + reply.header("Access-Control-Allow-Methods", "POST, OPTIONS"); + reply.header("Access-Control-Allow-Headers", "Content-Type"); + + const { topicID, apiKey, command, request, useDirect } = req.body || {}; + const ff1Config = getFF1Config(); + + // Build the DP-1 payload + const payload = { + command: command || "displayPlaylist", + request: request || {} + }; + + // Strategy 1: Try direct connection via SSH tunnel (in dev mode) + if (dev && ff1Config?.ip) { + try { + const result = await castViaSSH(ff1Config, payload); + return { success: true, method: "direct-ssh", response: result }; + } catch (sshErr) { + log(`โš ๏ธ FF1 SSH cast failed: ${sshErr.message}`); + // Fall through to cloud relay + } + } + + // Strategy 2: Try direct connection (if useDirect or localhost tunnel is running) + if (useDirect) { + const deviceUrl = `http://localhost:1111/api/cast`; + try { + log(`๐Ÿ“ก FF1 direct cast to ${deviceUrl}`); + const directResponse = await fetch(deviceUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), // 5s timeout + }); + + if (directResponse.ok) { + const result = await directResponse.json(); + return { success: true, method: "direct", response: result }; + } + log(`โš ๏ธ FF1 direct cast failed: ${directResponse.status}`); + } catch (directErr) { + log(`โš ๏ธ FF1 direct connection failed: ${directErr.message}`); + } + } + + // Strategy 3: Use cloud relay with topicID + const relayTopicId = topicID || ff1Config?.topicId; + if (!relayTopicId) { + reply.status(400); + return { + success: false, + error: "No topicID provided and no FF1 config found. Get topicID from your FF1 app settings." + }; + } + + const relayUrl = `${FF1_RELAY_URL}?topicID=${encodeURIComponent(relayTopicId)}`; + + try { + log(`โ˜๏ธ FF1 relay cast to ${relayUrl}`); + const headers = { "Content-Type": "application/json" }; + if (apiKey || ff1Config?.apiKey) { + headers["API-KEY"] = apiKey || ff1Config?.apiKey; + } + + const relayResponse = await fetch(relayUrl, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10000), // 10s timeout + }); + + const responseText = await relayResponse.text(); + let responseData; + try { + responseData = JSON.parse(responseText); + } catch { + responseData = { raw: responseText }; + } + + if (!relayResponse.ok) { + // Check if relay is down (404 or Cloudflare errors) + if (relayResponse.status === 404 || responseText.includes("error code:")) { + reply.status(503); + return { + success: false, + error: "FF1 cloud relay is unavailable", + hint: "The Feral File relay service appears to be down. Use ac-ff1 tunnel for local development.", + details: responseData + }; + } + reply.status(relayResponse.status); + return { success: false, error: `FF1 relay error: ${relayResponse.status}`, details: responseData }; + } + + return { success: true, method: "relay", response: responseData }; + } catch (relayErr) { + reply.status(500); + return { success: false, error: relayErr.message }; + } +}); + +// FF1 CORS preflight +fastify.options("/ff1/cast", async (req, reply) => { + reply.header("Access-Control-Allow-Origin", "*"); + reply.header("Access-Control-Allow-Methods", "POST, OPTIONS"); + reply.header("Access-Control-Allow-Headers", "Content-Type"); + return ""; +}); + +// *** Chat Log Endpoint (for system logs from other services) *** +fastify.post("/chat/log", async (req, reply) => { + const host = req.headers.host; + // Determine which chat instance based on a header or default to chat-system + const chatHost = req.headers["x-chat-instance"] || "chat-system.aesthetic.computer"; + const instance = chatManager.getInstance(chatHost); + + if (!instance) { + reply.status(404); + return { status: "error", message: "Unknown chat instance" }; + } + + const result = await chatManager.handleLog(instance, req.body, req.headers.authorization); + reply.status(result.status); + return result.body; +}); + +// *** Chat Status Endpoint *** +fastify.get("/chat/status", async (req) => { + return chatManager.getStatus(); +}); + +const PROFILE_SECRET_CACHE_MS = 60 * 1000; +let profileSecretCacheValue = null; +let profileSecretCacheAt = 0; + +function pickProfileStreamSecret(record) { + if (!record || typeof record !== "object") return null; + const candidates = [ + record.secret, + record.token, + record.profileSecret, + record.value, + ]; + for (const raw of candidates) { + if (!raw) continue; + const value = `${raw}`.trim(); + if (value) return value; + } + return null; +} + +function profileSecretsMatch(expected, provided) { + if (!expected || !provided) return false; + const left = Buffer.from(expected); + const right = Buffer.from(provided); + if (left.length !== right.length) return false; + try { + return crypto.timingSafeEqual(left, right); + } catch (_) { + return false; + } +} + +async function resolveProfileStreamSecret() { + const now = Date.now(); + if (profileSecretCacheAt && now - profileSecretCacheAt < PROFILE_SECRET_CACHE_MS) { + return profileSecretCacheValue; + } + + let resolved = null; + try { + if (chatManager?.db) { + const record = await chatManager.db + .collection("secrets") + .findOne({ _id: "profile-stream" }); + resolved = pickProfileStreamSecret(record); + } + } catch (err) { + error("๐Ÿ‘ค Could not load profile-stream secret from MongoDB:", err?.message || err); + } + + if (!resolved) { + const envSecret = `${process.env.PROFILE_STREAM_SECRET || ""}`.trim(); + resolved = envSecret || null; + } + + profileSecretCacheValue = resolved; + profileSecretCacheAt = now; + return profileSecretCacheValue; +} + +// *** Profile Stream Event Ingest *** +// Accepts server-to-server profile events from Netlify functions. +fastify.post("/profile-event", async (req, reply) => { + try { + const expectedSecret = await resolveProfileStreamSecret(); + const providedSecret = `${req.headers["x-profile-secret"] || ""}`.trim() || null; + if (expectedSecret && !profileSecretsMatch(expectedSecret, providedSecret)) { + reply.status(401); + return { ok: false, error: "Unauthorized" }; + } + + const body = req.body || {}; + const handle = body.handle; + const handleKey = normalizeProfileHandle(handle); + if (!handleKey) { + reply.status(400); + return { ok: false, error: "Missing or invalid handle" }; + } + + if (body.event && typeof body.event === "object") { + emitProfileActivity(handleKey, body.event); + } + + if (body.counts && typeof body.counts === "object") { + broadcastProfileStream(handleKey, "counts:update", { + handle: handleKey, + counts: body.counts, + }); + } + + if (body.countsDelta && typeof body.countsDelta === "object") { + emitProfileCountDelta(handleKey, body.countsDelta); + } + + if (body.presence && typeof body.presence === "object") { + broadcastProfileStream(handleKey, "presence:update", { + handle: handleKey, + reason: body.reason || "external", + changed: Array.isArray(body.changed) ? body.changed : [], + presence: body.presence, + }); + } + + return { ok: true }; + } catch (err) { + error("๐Ÿ‘ค profile-event ingest failed:", err); + reply.status(500); + return { ok: false, error: err.message }; + } +}); + +// *** Live Reload of Pieces in Development *** +if (dev) { + fastify.post("/reload", async (req) => { + everyone(pack("reload", req.body, "pieces")); + return { msg: "Reload request sent!", body: req.body }; + }); + + // Jump to a specific piece (navigate) + fastify.post("/jump", async (req) => { + const { piece } = req.body; + + // Broadcast to all browser clients + everyone(pack("jump", { piece }, "pieces")); + + // Send direct message to VSCode extension clients + vscodeClients.forEach(client => { + if (client?.readyState === WebSocket.OPEN) { + client.send(pack("vscode:jump", { piece }, "vscode")); + } + }); + + return { + msg: "Jump request sent!", + piece, + vscodeConnected: vscodeClients.size > 0 + }; + }); + + // GET /devices - List all connected clients with metadata and names + fastify.get("/devices", async () => { + const clientList = getClientStatus(); + // Enhance with device names and letters + const enhanced = clientList.map((c, index) => ({ + ...c, + letter: getDeviceLetter(c.id), + deviceName: deviceNames[c.ip]?.name || null, + deviceGroup: deviceNames[c.ip]?.group || null, + })); + return { + devices: enhanced, + host: { name: DEV_HOST_NAME, ip: DEV_LAN_IP }, + timestamp: Date.now() + }; + }); + + // GET /dev-info - Get dev host info for client overlay + fastify.get("/dev-info", async (req, reply) => { + // Add CORS headers for cross-origin requests from main site + reply.header("Access-Control-Allow-Origin", "*"); + reply.header("Access-Control-Allow-Methods", "GET"); + return { + host: DEV_HOST_NAME, + ip: DEV_LAN_IP, + mode: "LAN Dev", + timestamp: Date.now() + }; + }); + + // POST /jump/:target - Targeted jump (by ID, IP, handle, or device name) + fastify.post("/jump/:target", async (req) => { + const { target } = req.params; + const { piece, ahistorical, alias } = req.body; + + const targeted = targetClients(target); + if (targeted.length === 0) { + return { error: "No matching device", target }; + } + + targeted.forEach(({ ws }) => { + ws.send(pack("jump", { piece, ahistorical, alias }, "pieces")); + }); + + return { + msg: "Targeted jump sent", + piece, + count: targeted.length, + targets: targeted.map(t => t.id) + }; + }); + + // POST /reload/:target - Targeted reload + fastify.post("/reload/:target", async (req) => { + const { target } = req.params; + const targeted = targetClients(target); + + targeted.forEach(({ ws }) => { + ws.send(pack("reload", req.body, "pieces")); + }); + + return { msg: "Targeted reload sent", count: targeted.length }; + }); + + // POST /piece-reload/:target - Targeted KidLisp reload + fastify.post("/piece-reload/:target", async (req) => { + const { target } = req.params; + const { source, createCode, authToken } = req.body; + const targeted = targetClients(target); + + targeted.forEach(({ ws }) => { + ws.send(pack("piece-reload", { source, createCode, authToken }, "kidlisp")); + }); + + return { msg: "Targeted piece-reload sent", count: targeted.length }; + }); + + // POST /device/name - Set a friendly name for a device by IP + fastify.post("/device/name", async (req) => { + const { ip, name, group } = req.body; + if (!ip) return { error: "IP required" }; + + const cleanIp = ip.replace('::ffff:', ''); + if (name) { + deviceNames[cleanIp] = { name, group: group || null, updatedAt: Date.now() }; + } else { + delete deviceNames[cleanIp]; + } + saveDeviceNames(); + + // Notify the device of its new name + const targeted = targetClients(cleanIp); + targeted.forEach(({ ws }) => { + ws.send(pack("dev:identity", { + name, + host: DEV_HOST_NAME, + hostIp: DEV_LAN_IP, + mode: "LAN Dev" + }, "dev")); + }); + + return { + msg: name ? "Device named" : "Device name cleared", + ip: cleanIp, + name, + notified: targeted.length + }; + }); + + // GET /device/names - List all device names + fastify.get("/device/names", async () => { + return { names: deviceNames }; + }); +} + +// *** HTTP Server Initialization *** + +// Track UDP channels manually (geckos.io doesn't expose this) +const udpChannels = {}; + +// ๐Ÿฉฐ Initialize geckos.io BEFORE server starts listening +// Configure for devcontainer/Docker environment: +// - iceServers: Use local TURN server for relay (required in Docker/devcontainer) +// - portRange: constrain UDP to small range that can be exposed from container +// - cors: allow from any origin in dev mode + +// Detect external IP for TURN server (browsers need to reach TURN from outside container) +// In devcontainer, we expose ports to the host, so use the host's LAN IP +// Priority: TURN_HOST env var > DEV_LAN_IP > localhost +const getExternalTurnHost = () => { + // Check for explicitly set TURN host + if (process.env.TURN_HOST) return process.env.TURN_HOST; + // Use the DEV_LAN_IP if available (detected earlier) + if (DEV_LAN_IP) return DEV_LAN_IP; + // Fallback to localhost (won't work for external clients but ok for local testing) + return 'localhost'; +}; + +const turnHost = getExternalTurnHost(); +console.log("๐Ÿฉฐ TURN server host for ICE:", turnHost); + +const devIceServers = [ + { urls: `stun:${turnHost}:3478` }, + { + urls: `turn:${turnHost}:3478`, + username: 'aesthetic', + credential: 'computer123' + }, +]; +const prodIceServers = [ + { urls: 'stun:stun.l.google.com:19302' }, + // TODO: Add production TURN server +]; + +const io = geckos({ + iceServers: dev ? devIceServers : prodIceServers, + // Force relay-only mode in dev to work through container networking + // Direct UDP won't work from host browser to container internal IP + iceTransportPolicy: dev ? 'relay' : 'all', + portRange: { + min: 10000, + max: 10007, + }, + cors: { + allowAuthorization: true, + origin: dev ? "*" : (req) => { + const allowed = ["https://aesthetic.computer", "https://notepat.com", "https://kidlisp.com", "https://pj.kidlisp.com"]; + const reqOrigin = req.headers?.origin; + return allowed.includes(reqOrigin) ? reqOrigin : allowed[0]; + }, + }, +}); +io.addServer(server); // Hook up to the HTTP Server - must be before listen() +console.log("๐Ÿฉฐ Geckos.io server attached to fastify server (UDP ports 10000-10007)"); + +const start = async () => { + try { + if (dev) { + fastify.listen({ + host: "0.0.0.0", // ip.address(), + port: info.port, + }); + } else { + fastify.listen({ host: "0.0.0.0", port: info.port }); + } + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +}; + +await start(); + +// *** Status Page Data Collection *** + +// Get unified client status - user-centric view +function getClientStatus() { + const identityMap = new Map(); // Map by identity (handle or user or IP) + + // Helper to get identity key for a client + const getIdentityKey = (client) => { + // Priority: handle > user > IP (for grouping same person) + if (client.handle) return `handle:${client.handle}`; + if (client.user) return `user:${client.user}`; + if (client.ip) return `ip:${client.ip}`; + return null; + }; + + // Process all WebSocket connections + Object.keys(connections).forEach((id) => { + const client = clients[id] || {}; + const ws = connections[id]; + const identityKey = getIdentityKey(client); + + if (!identityKey) return; // Skip if no identity info + + if (!identityMap.has(identityKey)) { + identityMap.set(identityKey, { + handle: client.handle || null, + location: client.location || null, + ip: client.ip || null, + geo: client.geo || null, + connectionIds: { websocket: [], udp: [] }, + protocols: { websocket: false, udp: false }, + connections: { websocket: [], udp: [] } + }); + } + + const identity = identityMap.get(identityKey); + + // Update with latest info + if (client.handle && !identity.handle) identity.handle = client.handle; + if (client.location) identity.location = client.location; + if (client.ip && !identity.ip) identity.ip = client.ip; + if (client.geo && !identity.geo) identity.geo = client.geo; + + identity.connectionIds.websocket.push(parseInt(id)); + identity.protocols.websocket = true; + identity.connections.websocket.push({ + id: parseInt(id), + alive: ws.isAlive || false, + readyState: ws.readyState, + ping: ws.lastPing || null, + codeChannel: findCodeChannel(parseInt(id)), + worlds: getWorldMemberships(parseInt(id)) + }); + }); + + // Process all UDP connections + Object.keys(udpChannels).forEach((id) => { + const client = clients[id] || {}; + const udp = udpChannels[id]; + const identityKey = getIdentityKey(client); + + if (!identityKey) return; // Skip if no identity info + + if (!identityMap.has(identityKey)) { + identityMap.set(identityKey, { + handle: client.handle || null, + location: client.location || null, + ip: client.ip || null, + geo: client.geo || null, + connectionIds: { websocket: [], udp: [] }, + protocols: { websocket: false, udp: false }, + connections: { websocket: [], udp: [] } + }); + } + + const identity = identityMap.get(identityKey); + + // Update with latest info + if (client.handle && !identity.handle) identity.handle = client.handle; + if (client.location) identity.location = client.location; + if (client.ip && !identity.ip) identity.ip = client.ip; + if (client.geo && !identity.geo) identity.geo = client.geo; + + identity.connectionIds.udp.push(id); + identity.protocols.udp = true; + identity.connections.udp.push({ + id: id, + connectedAt: udp.connectedAt, + state: udp.state || 'unknown' + }); + }); + + // Convert to array and add summary info + return Array.from(identityMap.values()).map(identity => { + const wsCount = identity.connectionIds.websocket.length; + const udpCount = identity.connectionIds.udp.length; + const totalConnections = wsCount + udpCount; + + return { + handle: identity.handle, + location: identity.location, + ip: identity.ip, + geo: identity.geo, + protocols: identity.protocols, + connectionCount: { + websocket: wsCount, + udp: udpCount, + total: totalConnections + }, + // Simplified connection info - just take first of each type for display + websocket: identity.connections.websocket.length > 0 ? identity.connections.websocket[0] : null, + udp: identity.connections.udp.length > 0 ? identity.connections.udp[0] : null, + multipleTabs: totalConnections > 1 + }; + }); +} + +function getWorldMemberships(connectionId) { + const worlds = []; + Object.keys(worldClients).forEach(piece => { + if (worldClients[piece][connectionId]) { + worlds.push({ + piece, + handle: worldClients[piece][connectionId].handle, + showing: worldClients[piece][connectionId].showing, + ghost: worldClients[piece][connectionId].ghost || false, + }); + } + }); + return worlds; +} + +function findCodeChannel(connectionId) { + for (const [channel, subscribers] of Object.entries(codeChannels)) { + if (subscribers.has(connectionId)) return channel; + } + return null; +} + +function getFullStatus() { + const clientList = getClientStatus(); + + // Get chat status with recent messages + const chatStatus = chatManager.getStatus(); + const chatWithMessages = chatStatus.map(instance => { + // Don't expose sotce chat messages โ€” it's a paid subscriber network. + const isSotce = instance.name === "chat-sotce"; + const recentMessages = (!isSotce && instance.messages > 0) + ? chatManager.getRecentMessages(instance.host, 5) + : []; + return { + ...instance, + recentMessages + }; + }); + + // Filter old errors + const cutoff = Date.now() - ERROR_RETENTION_MS; + const recentErrors = errorLog.filter(e => new Date(e.timestamp).getTime() > cutoff); + + return { + timestamp: Date.now(), + server: { + uptime: process.uptime(), + environment: dev ? "development" : "production", + port: info.port, + }, + totals: { + websocket: wss.clients.size, + udp: Object.keys(udpChannels).length, + unique_clients: clientList.length + }, + clients: clientList, + chat: chatWithMessages, + errors: recentErrors.slice(-20).reverse() // Most recent first + }; +} + + +// *** Socket Server Initialization *** +// #region socket +let wss; +let connections = {}; // All active WebSocket connections. +const worldClients = {}; // All connected ๐Ÿง’ to a space like `field`. + +let connectionId = 0; // TODO: Eventually replace with a username arrived at through +// a client <-> server authentication function. + +wss = new WebSocketServer({ server }); +log( + `๐Ÿค– session.aesthetic.computer (${ + dev ? "Development" : "Production" + }) socket: wss://${ip.address()}:${info.port}`, +); + +// *** Status Page Routes (defined after wss initialization) *** +// Status JSON endpoint +fastify.get("/status", async (request, reply) => { + return getFullStatus(); +}); + +// Status dashboard HTML at root +fastify.get("/", async (request, reply) => { + reply.type("text/html"); + return ` + + + + + session-server + + + +
+

๐Ÿงฉ session-server

+
+ ๐Ÿ”ด | + Uptime: -- | + Online: 0 | + Chat: 0 +
+
+ +
+ + + +
+ +
+
+
+

๐Ÿง‘โ€๐Ÿ’ป Connected Clients

+
+
+
+

๐Ÿ’ฌ Chat Instances

+
+
+
+
+ +
+
+
+

๐Ÿ’ฌ chat-system

+
+
+
+

๐Ÿ• chat-clock

+
+
+ +
+
+ +
+
+

โš ๏ธ Recent Errors & Warnings

+
+
+
+ + + +`; +}); + +// Pack messages into a simple object protocol of `{type, content}`. +function pack(type, content, id) { + return JSON.stringify({ type, content, id }); +} + +// Enable ping-pong behavior to keep connections persistently tracked. +// (In the future could just tie connections to logged in users or +// persistent tokens to keep persistence.) +const interval = setInterval(function ping() { + wss.clients.forEach((client) => { + if (client.isAlive === false) { + return client.terminate(); + } + client.isAlive = false; + client.pingStart = Date.now(); // Start ping timer + client.ping(); + }); +}, 15000); // 15 second pings from server before termination. + +wss.on("close", function close() { + clearInterval(interval); + connections = {}; +}); + +// Construct the server. +wss.on("connection", async (ws, req) => { + const connectionInfo = { + url: req.url, + host: req.headers.host, + origin: req.headers.origin, + userAgent: req.headers['user-agent'], + remoteAddress: req.socket.remoteAddress, + }; + log('๐Ÿ”Œ WebSocket connection received:', JSON.stringify(connectionInfo, null, 2)); + log('๐Ÿ”Œ Total wss.clients.size:', wss.clients.size); + log('๐Ÿ”Œ Current connections count:', Object.keys(connections).length); + + // Route status dashboard WebSocket connections separately + if (req.url === '/status-stream') { + log('๐Ÿ“Š Status dashboard viewer connected from:', req.socket.remoteAddress); + statusClients.add(ws); + + // Mark as dashboard viewer (don't add to game clients) + ws.isDashboardViewer = true; + + // Send initial state + ws.send(JSON.stringify({ + type: 'status', + data: getFullStatus(), + })); + + ws.on('close', () => { + log('๐Ÿ“Š Status dashboard viewer disconnected'); + statusClients.delete(ws); + }); + + ws.on('error', (err) => { + error('๐Ÿ“Š Status dashboard error:', err); + statusClients.delete(ws); + }); + + return; // Don't process as a game client + } + + // Route targeted profile stream connections + if (req.url?.startsWith('/profile-stream')) { + let requestedHandle = null; + try { + const parsedUrl = new URL(req.url, 'http://localhost'); + requestedHandle = parsedUrl.searchParams.get('handle'); + } catch (err) { + error('๐Ÿ‘ค Invalid profile-stream URL:', err); + } + + const key = addProfileStreamClient(ws, requestedHandle); + if (!key) { + ws.send( + JSON.stringify({ + type: 'profile:error', + data: { message: 'Missing or invalid handle query param.' }, + }), + ); + try { ws.close(); } catch (_) {} + return; + } + + log('๐Ÿ‘ค Profile stream viewer connected for:', key, 'from:', req.socket.remoteAddress); + + ws.on('close', () => { + removeProfileStreamClient(ws); + log('๐Ÿ‘ค Profile stream viewer disconnected for:', key); + }); + + ws.on('error', (err) => { + error('๐Ÿ‘ค Profile stream error:', err); + removeProfileStreamClient(ws); + }); + + return; // Don't process as a game client + } + + // Route chat connections to ChatManager based on host + const host = req.headers.host; + if (chatManager.isChatHost(host)) { + log('๐Ÿ’ฌ Chat client connection from:', host); + chatManager.handleConnection(ws, req); + return; // Don't process as a game client + } + + // Route AC Machines connections โ€” device monitoring & remote commands + if (req.url.startsWith('/machines')) { + const urlParams = new URL(req.url, 'http://localhost').searchParams; + const role = urlParams.get('role') || 'device'; + const token = urlParams.get('token') || ''; + const machineId = urlParams.get('machineId') || ''; + + if (role === 'viewer') { + // Browser dashboard viewer โ€” verify AC auth token via Auth0 + const authUser = await verifyACToken(token); + if (!authUser?.sub) { + ws.close(4001, 'Unauthorized'); + return; + } + const userSub = authUser.sub; + const userHandle = authUser.nickname || authUser.name || null; + + log(`Machines viewer connected: ${userHandle || userSub}`); + + if (!machinesViewers.has(userSub)) machinesViewers.set(userSub, new Set()); + machinesViewers.get(userSub).add(ws); + + // Send initial state: all online machines for this user + const userMachines = []; + for (const [mid, device] of machinesDevices) { + if (device.user === userSub) { + userMachines.push({ + machineId: mid, + ...device.info, + status: "online", + lastHeartbeat: device.lastHeartbeat, + }); + } + } + ws.send(JSON.stringify({ type: "machines-state", machines: userMachines })); + + // Handle viewer โ†’ device commands + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === "command" && msg.machineId) { + const device = machinesDevices.get(msg.machineId); + if (device && device.user === userSub && device.ws.readyState === WebSocket.OPEN) { + const commandId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + device.ws.send(JSON.stringify({ + type: "command", + command: msg.cmd, + commandId, + target: msg.args?.target || msg.args?.piece || undefined, + // Free-text payload for cmd:"prompt" โ€” runs through the + // device's prompt.mjs execute() exactly as if typed locally. + text: typeof msg.args?.text === "string" ? msg.args.text : undefined, + })); + log(`Command '${msg.cmd}' โ†’ ${msg.machineId} (${commandId})`); + } + } + // Swank eval: forward CL expression to device for evaluation + if (msg.type === "swank:eval" && msg.machineId && msg.expr) { + const device = machinesDevices.get(msg.machineId); + if (device && device.user === userSub && device.ws.readyState === WebSocket.OPEN) { + const evalId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + device.ws.send(JSON.stringify({ + type: "swank:eval", + expr: msg.expr, + evalId, + })); + log(`๐Ÿ”ฎ Swank eval โ†’ ${msg.machineId}: ${msg.expr.slice(0, 60)}`); + } + } + } catch (e) { + error('๐Ÿ–ฅ๏ธ Machines viewer message error:', e); + } + }); + + ws.on('close', () => { + log(`๐Ÿ–ฅ๏ธ Machines viewer disconnected: ${userHandle || userSub}`); + const viewers = machinesViewers.get(userSub); + if (viewers) { + viewers.delete(ws); + if (viewers.size === 0) machinesViewers.delete(userSub); + } + }); + + ws.on('error', (err) => { + error('๐Ÿ–ฅ๏ธ Machines viewer error:', err); + const viewers = machinesViewers.get(userSub); + if (viewers) { + viewers.delete(ws); + if (viewers.size === 0) machinesViewers.delete(userSub); + } + }); + + } else { + // Device connection + const tokenPayload = await verifyMachineToken(token); + const userSub = tokenPayload?.sub || null; + const userHandle = tokenPayload?.handle || null; + const linked = !!tokenPayload; + + log(`๐Ÿ“ก Machines device connected: ${machineId} (${linked ? userHandle : 'unlinked'})`); + + machinesDevices.set(machineId, { + ws, user: userSub, handle: userHandle, machineId, linked, + info: {}, lastHeartbeat: Date.now(), + }); + + if (userSub) { + broadcastToMachineViewers(userSub, { type: "device-connected", machineId, linked }); + } + + ws.on('message', async (data) => { + try { + const msg = JSON.parse(data.toString()); + const device = machinesDevices.get(machineId); + if (!device) return; + + switch (msg.type) { + case "register": + device.info = { + version: msg.version, buildName: msg.buildName, + gitHash: msg.gitHash, buildTs: msg.buildTs, + hw: msg.hw, ip: msg.ip, wifiSSID: msg.wifiSSID, + hostname: msg.hostname, label: msg.label, + currentPiece: msg.currentPiece || "notepat", + }; + device.lastHeartbeat = Date.now(); + try { await upsertMachine(userSub, machineId, device.info); } catch (e) { error("๐Ÿ“ก upsert:", e.message); } + if (userSub) broadcastToMachineViewers(userSub, { type: "machine-registered", machineId, ...device.info, status: "online" }); + break; + + case "heartbeat": + device.lastHeartbeat = Date.now(); + device.info.uptime = msg.uptime; + device.info.currentPiece = msg.currentPiece || device.info.currentPiece; + device.info.battery = msg.battery; + device.info.charging = msg.charging; + device.info.fps = msg.fps; + try { await updateMachineHeartbeat(userSub, machineId, msg.uptime, device.info.currentPiece); } catch (e) { error("๐Ÿ“ก heartbeat:", e.message); } + if (userSub) broadcastToMachineViewers(userSub, { + type: "heartbeat", machineId, uptime: msg.uptime, + currentPiece: device.info.currentPiece, + battery: msg.battery, charging: msg.charging, fps: msg.fps, + timestamp: Date.now(), + }); + break; + + case "log": + try { await insertMachineLog(userSub, machineId, msg); } catch (e) { error("๐Ÿ“ก log insert:", e.message); } + if (userSub) { + const logMessage = msg.message || (typeof msg.data === "string" ? msg.data : JSON.stringify(msg.data)); + broadcastToMachineViewers(userSub, { + type: "log", machineId, level: msg.logType === "crash" ? "error" : (msg.level || "info"), + message: logMessage, logType: msg.logType || "log", + data: msg.data || null, + when: msg.when || new Date().toISOString(), + }); + } + break; + + case "command-ack": + case "command-response": + if (userSub) broadcastToMachineViewers(userSub, { type: msg.type, machineId, commandId: msg.commandId, command: msg.command, data: msg.data }); + break; + + case "swank:result": + // Forward Swank eval result from device to viewer + if (userSub) broadcastToMachineViewers(userSub, { + type: "swank:result", machineId, + evalId: msg.evalId, ok: msg.ok, result: msg.result, + }); + break; + } + } catch (e) { + error('๐Ÿ“ก Machines device message error:', e); + } + }); + + ws.on('close', async () => { + log(`๐Ÿ“ก Machines device disconnected: ${machineId}`); + machinesDevices.delete(machineId); + if (userSub) { + broadcastToMachineViewers(userSub, { type: "status-change", machineId, status: "offline" }); + try { await setMachineOffline(userSub, machineId); } catch (e) { error("๐Ÿ“ก offline:", e.message); } + } + }); + + ws.on('error', (err) => { + error(`๐Ÿ“ก Machines device error (${machineId}):`, err); + machinesDevices.delete(machineId); + }); + } + + return; // Don't process as a game client + } + + // Route socklogs connections - devices sending logs and viewers subscribing + if (req.url.startsWith('/socklogs')) { + const urlParams = new URL(req.url, 'http://localhost').searchParams; + const role = urlParams.get('role') || 'device'; // 'device' or 'viewer' + const deviceId = urlParams.get('deviceId') || `device-${Date.now()}`; + + if (role === 'viewer') { + // Viewer wants to see logs from devices + log('๐Ÿ‘๏ธ SockLogs viewer connected'); + socklogsViewers.add(ws); + + // Send current status + ws.send(JSON.stringify({ + type: 'status', + ...socklogsStatus() + })); + + ws.on('close', () => { + log('๐Ÿ‘๏ธ SockLogs viewer disconnected'); + socklogsViewers.delete(ws); + }); + + ws.on('error', (err) => { + error('๐Ÿ‘๏ธ SockLogs viewer error:', err); + socklogsViewers.delete(ws); + }); + } else { + // Device sending logs + log(`๐Ÿ“ฑ SockLogs device connected: ${deviceId}`); + socklogsDevices.set(deviceId, { + ws, + logCount: 0, + lastLog: null, + connectedAt: Date.now() + }); + + // Notify viewers of new device + for (const viewer of socklogsViewers) { + if (viewer.readyState === WebSocket.OPEN) { + viewer.send(JSON.stringify({ + type: 'device-connected', + deviceId, + status: socklogsStatus() + })); + } + } + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'log') { + const device = socklogsDevices.get(deviceId); + if (device) { + device.logCount++; + device.lastLog = Date.now(); + } + socklogsBroadcast(deviceId, msg); + } + } catch (e) { + error('๐Ÿ“ฑ SockLogs parse error:', e); + } + }); + + ws.on('close', () => { + log(`๐Ÿ“ฑ SockLogs device disconnected: ${deviceId}`); + socklogsDevices.delete(deviceId); + + // Notify viewers + for (const viewer of socklogsViewers) { + if (viewer.readyState === WebSocket.OPEN) { + viewer.send(JSON.stringify({ + type: 'device-disconnected', + deviceId, + status: socklogsStatus() + })); + } + } + }); + + ws.on('error', (err) => { + error(`๐Ÿ“ฑ SockLogs device error (${deviceId}):`, err); + socklogsDevices.delete(deviceId); + }); + } + + return; // Don't process as a game client + } + + log('๐ŸŽฎ Game client connection detected, adding to connections'); + + // Regular game client connection handling below + const ip = req.socket.remoteAddress || "localhost"; // beautify ip + ws.isAlive = true; // For checking persistence between ping-pong messages. + ws.pingStart = null; // Track ping timing + ws.lastPing = null; // Store last measured ping + + ws.on("pong", () => { + ws.isAlive = true; + if (ws.pingStart) { + ws.lastPing = Date.now() - ws.pingStart; + ws.pingStart = null; + } + }); // Receive a pong and stay alive! + + // Assign the conection a unique id. + connections[connectionId] = ws; + const id = connectionId; + let codeChannel; // Used to subscribe to incoming piece code. + + // Initialize client record with IP and geolocation + if (!clients[id]) clients[id] = {}; + clients[id].websocket = true; + + // Clean IP and get geolocation + const cleanIp = ip.replace('::ffff:', ''); + clients[id].ip = cleanIp; + + const geo = geoip.lookup(cleanIp); + if (geo) { + clients[id].geo = { + country: geo.country, + region: geo.region, + city: geo.city, + timezone: geo.timezone, + ll: geo.ll // [latitude, longitude] + }; + log(`๐ŸŒ Geolocation for ${cleanIp}:`, geo.country, geo.region, geo.city); + } else { + log(`๐ŸŒ No geolocation data for ${cleanIp}`); + } + + log("๐Ÿง Someone joined:", `${id}:${ip}`, "Online:", wss.clients.size, "๐Ÿซ‚"); + log("๐ŸŽฎ Added to connections. Total game clients:", Object.keys(connections).length); + + const content = { id, playerCount: wss.clients.size }; + + // Send a message to all other clients except this one. + function others(string) { + wss.clients.forEach((c) => { + if (c !== ws && c?.readyState === WebSocket.OPEN) c.send(string); + }); + } + + // Send a self-connection message back to the client. + ws.send( + pack( + "connected", + JSON.stringify({ ip, playerCount: content.playerCount }), + id, + ), + ); + + // In dev mode, send device identity info for LAN overlay + if (dev) { + const deviceName = deviceNames[cleanIp]?.name || null; + const deviceLetter = getDeviceLetter(id); + const identityPayload = { + name: deviceName, + letter: deviceLetter, + host: DEV_HOST_NAME, + hostIp: DEV_LAN_IP, + mode: "LAN Dev", + connectionId: id, + }; + console.log(`๐Ÿ“ฑ Sending dev:identity to ${cleanIp}:`, identityPayload); + ws.send(pack("dev:identity", identityPayload, "dev")); + } + + // Send a join message to everyone else. + others( + pack( + "joined", + JSON.stringify({ + text: `${connectionId} has joined. Connections open: ${content.playerCount}`, + }), + id, + ), + ); + + connectionId += 1; + + // Relay all incoming messages from this client to everyone else. + ws.on("message", (data) => { + // Parse incoming message and attach client identifier. + let msg; + try { + msg = JSON.parse(data.toString()); + } catch (error) { + console.error("๐Ÿ“š Failed to parse JSON:", error); + return; + } + + // ๐Ÿ“ฆ Module streaming - handle module requests before other processing + if (msg.type === "module:request") { + const modulePath = msg.path; + const withDeps = msg.withDeps === true; // Request all dependencies too + const knownHashes = msg.knownHashes || {}; // Client's cached hashes + + if (withDeps) { + // Recursively gather all dependencies + const modules = {}; + let skippedCount = 0; + + const gatherDeps = (p, fromPath = null) => { + if (modules[p] || modules[p] === null) return; // Already gathered (or marked as cached) + const data = getModuleHash(p); + if (!data) { + // Only warn for top-level not found, not for deps (which might be optional) + if (!fromPath) log(`๐Ÿ“ฆ Module not found: ${p}`); + return; + } + + // Check if client already has this hash cached + if (knownHashes[p] === data.hash) { + modules[p] = null; // Mark as "client has it" - don't send content + skippedCount++; + } else { + modules[p] = { hash: data.hash, content: data.content }; + } + + // Debug: show when gathering specific important modules + if (p.includes('headers') || p.includes('kidlisp')) { + log(`๐Ÿ“ฆ Gathering ${p} (from ${fromPath || 'top'})${knownHashes[p] === data.hash ? ' [cached]' : ''}`); + } + + // Parse static imports from content - match ES module import/export from statements + // This regex only matches valid relative imports ending in .mjs or .js + // Skip commented lines by checking each line doesn't start with // + const staticImportRegex = /^(?!\s*\/\/).*?(?:import|export)\s+(?:[^;]*?\s+from\s+)?["'](\.{1,2}\/[^"'\s]+\.m?js)["']/gm; + let match; + while ((match = staticImportRegex.exec(data.content)) !== null) { + const importPath = match[1]; + // Skip invalid paths + if (importPath.includes('...') || importPath.length > 200) continue; + + // Resolve relative path + const dir = path.dirname(p); + const resolved = path.normalize(path.join(dir, importPath)); + log(`๐Ÿ“ฆ Found dep: ${p} -> ${importPath} (resolved: ${resolved})`); + gatherDeps(resolved, p); + } + + // Parse dynamic imports - import("./path") or import('./path') or import(`./path`) + // Skip commented lines + const dynamicImportRegex = /^(?!\s*\/\/).*?import\s*\(\s*["'`](\.{1,2}\/[^"'`\s]+\.m?js)["'`]\s*\)/gm; + while ((match = dynamicImportRegex.exec(data.content)) !== null) { + const importPath = match[1]; + // Skip invalid paths + if (importPath.includes('...') || importPath.length > 200) continue; + + // Resolve relative path + const dir = path.dirname(p); + const resolved = path.normalize(path.join(dir, importPath)); + gatherDeps(resolved, p); + } + }; + + gatherDeps(modulePath); + + // Filter out null entries (modules client already has) and count + const modulesToSend = {}; + const cachedPaths = []; + for (const [p, data] of Object.entries(modules)) { + if (data === null) { + cachedPaths.push(p); + } else { + modulesToSend[p] = data; + } + } + + const totalModules = Object.keys(modules).length; + const sentModules = Object.keys(modulesToSend).length; + + if (totalModules > 0) { + // Log bundle stats + if (skippedCount > 0) { + log(`๐Ÿ“ฆ Bundle for ${modulePath}: ${sentModules}/${totalModules} sent (${skippedCount} cached)`); + } else { + log(`๐Ÿ“ฆ Bundle for ${modulePath}: ${sentModules} modules`); + } + + ws.send(JSON.stringify({ + type: "module:bundle", + entry: modulePath, + modules: modulesToSend, + cached: cachedPaths // Tell client which paths to use from cache + })); + } else { + ws.send(JSON.stringify({ + type: "module:error", + path: modulePath, + error: "Module not found" + })); + } + } else { + // Single module request (original behavior) + const moduleData = getModuleHash(modulePath); + + if (moduleData) { + ws.send(JSON.stringify({ + type: "module:response", + path: modulePath, + hash: moduleData.hash, + content: moduleData.content + })); + log(`๐Ÿ“ฆ Module sent: ${modulePath} (${moduleData.content.length} bytes)`); + } else { + ws.send(JSON.stringify({ + type: "module:error", + path: modulePath, + error: "Module not found" + })); + log(`๐Ÿ“ฆ Module not found: ${modulePath}`); + } + } + return; + } + + if (msg.type === "module:check") { + const modulePath = msg.path; + const clientHash = msg.hash; + const moduleData = getModuleHash(modulePath); + + if (moduleData) { + ws.send(JSON.stringify({ + type: "module:status", + path: modulePath, + changed: moduleData.hash !== clientHash, + hash: moduleData.hash + })); + } else { + ws.send(JSON.stringify({ + type: "module:status", + path: modulePath, + changed: true, + hash: null, + error: "Module not found" + })); + } + return; + } + + if (msg.type === "module:list") { + // Return list of available modules (for prefetching) + const modules = [ + "lib/disk.mjs", + "lib/graph.mjs", + "lib/num.mjs", + "lib/geo.mjs", + "lib/parse.mjs", + "lib/help.mjs", + "lib/text.mjs", + "bios.mjs" + ]; + const moduleInfo = modules.map(p => { + const data = getModuleHash(p); + return data ? { path: p, hash: data.hash, size: data.content.length } : null; + }).filter(Boolean); + + ws.send(JSON.stringify({ + type: "module:list", + modules: moduleInfo + })); + return; + } + + // ๐ŸŽน DAW Channel - M4L device โ†” IDE communication + if (msg.type === "daw:join") { + // Device (kidlisp.com/device) joining to receive code + dawDevices.add(id); + log(`๐ŸŽน DAW device joined: ${id} (total: ${dawDevices.size})`); + ws.send(JSON.stringify({ type: "daw:joined", id })); + return; + } + + if (msg.type === "daw:code") { + // IDE sending code to all connected devices + log(`๐ŸŽน DAW code broadcast from ${id} to ${dawDevices.size} devices`); + const codeMsg = JSON.stringify({ + type: "daw:code", + content: msg.content, + from: id + }); + + // Broadcast to all DAW devices + for (const deviceId of dawDevices) { + const deviceWs = connections[deviceId]; + if (deviceWs && deviceWs.readyState === WebSocket.OPEN) { + deviceWs.send(codeMsg); + log(`๐ŸŽน Sent code to device ${deviceId}`); + } + } + return; + } + + if (msg.type === "notepat:midi:sources") { + sendNotepatMidiSources(ws); + return; + } + + if (msg.type === "notepat:midi:subscribe") { + const filter = msg.content || {}; + addNotepatMidiSubscriber(id, ws, filter); + return; + } + + if (msg.type === "notepat:midi:unsubscribe") { + removeNotepatMidiSubscriber(id); + if (ws.readyState === WebSocket.OPEN) { + ws.send(pack("notepat:midi:unsubscribed", true, "midi-relay")); + } + return; + } + + // ๐ŸŽน Browser-side notepat (notepat.com on web) publishes MIDI events + // via WS. Native bare-metal sends the same payload via raw UDP at port + // 10010 (see handleNotepatMidiUdpPacket). Both paths converge through + // broadcastNotepatMidiEvent, fanning out to WS + UDP subscribers (M4L + // notepat-remote devices listen on those channels). Heartbeats keep + // the source visible in `notepat:midi:sources` between notes. + if (msg.type === "notepat:midi:publish" || msg.type === "notepat:midi:heartbeat") { + const payload = msg.content || {}; + const now = Date.now(); + const source = upsertNotepatMidiSource({ + handle: payload.handle, + machineId: payload.machineId, + piece: payload.piece || "notepat", + lastEvent: msg.type === "notepat:midi:heartbeat" ? "heartbeat" : payload.event, + ts: now, + }); + if (msg.type === "notepat:midi:heartbeat") return; + if (!source.handle && !source.machineId) return; + const rawNote = Number(payload.note); + const rawVelocity = Number(payload.velocity); + const rawChannel = Number(payload.channel); + if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) return; + let event = payload.event === "note_off" ? "note_off" : "note_on"; + const note = Math.max(0, Math.min(127, Math.round(rawNote))); + const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); + const channel = Math.max(0, Math.min(15, Math.round(rawChannel))); + if (event === "note_on" && velocity === 0) event = "note_off"; + broadcastNotepatMidiEvent({ + type: "notepat:midi", + event, + note, + velocity, + channel, + handle: source.handle, + machineId: source.machineId, + piece: source.piece || "notepat", + ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, + }); + return; + } + + msg.id = id; // TODO: When sending a server generated message, use a special id. + + // Extract user identity and handle from ANY message that contains it + if (msg.content?.user?.sub) { + if (!clients[id]) clients[id] = { websocket: true }; + + const userSub = msg.content.user.sub; + const userChanged = !clients[id].user || clients[id].user !== userSub; + + if (userChanged) { + clients[id].user = userSub; + log("๐Ÿ”‘ User identity from", msg.type + ":", userSub.substring(0, 20) + "...", "conn:", id); + } + + // Extract handle from message if present (e.g., location:broadcast includes it) + if (msg.content.handle && (!clients[id].handle || clients[id].handle !== msg.content.handle)) { + clients[id].handle = msg.content.handle; + log("โœ… Handle from message:", msg.content.handle, "conn:", id); + emitProfilePresence(msg.content.handle, "identify", ["handle"]); + } + } + + if (msg.type === "scream") { + // Alert all connected users via redis pub/sub to the scream. + log("๐Ÿ˜ฑ About to scream..."); + const out = filter(msg.content); + pub + .publish("scream", out) + .then((result) => { + log("๐Ÿ˜ฑ Scream succesfully published:", result); + + let piece = ""; + if (out.indexOf("pond") > -1) piece = "pond"; + else if (out.indexOf("field") > -1) piece = "field"; + + //if (!dev) { + getMessaging() + .send({ + notification: { + title: "๐Ÿ˜ฑ Scream", + body: out, //, + }, + // android: { + // notification: { + // imageUrl: "https://aesthetic.computer/api/logo.png", + // }, + apns: { + payload: { + aps: { + "mutable-content": 1, + "interruption-level": "time-sensitive", // Marks as time-sensitive + priority: 10, // Highest priority + "content-available": 1, // Tells iOS to wake the app + }, + }, + headers: { + "apns-priority": "10", // Immediate delivery priority + "apns-push-type": "alert", // Explicit push type + "apns-expiration": "0", // Message won't be stored by APNs + }, + fcm_options: { + image: "https://aesthetic.computer/api/logo.png", + }, + }, + webpush: { + headers: { + image: "https://aesthetic.computer/api/logo.png", + }, + }, + topic: "scream", + data: { piece }, + }) + .then((response) => { + log("โ˜Ž๏ธ Successfully sent notification:", response); + }) + .catch((error) => { + log("๐Ÿ“ต Error sending notification:", error); + }); + //} + }) + .catch((error) => { + log("๐Ÿ™…โ€โ™€๏ธ Error publishing scream:", error); + }); + // Send a notification to all devices subscribed to the `scream` topic. + } else if (msg.type === "code-channel:sub") { + // Filter code-channel updates based on this user. + codeChannel = msg.content; + if (!codeChannels[codeChannel]) codeChannels[codeChannel] = new Set(); + codeChannels[codeChannel].add(id); + + // Send current channel state to late joiners + if (codeChannelState[codeChannel]) { + // Note: codeChannelState stores the original msg.content object, + // pack() will JSON.stringify it, so don't double-stringify here + const stateMsg = pack("code", codeChannelState[codeChannel], id); + send(stateMsg); + log(`๐Ÿ“ฅ Sent current state to late joiner on channel ${codeChannel}`); + } + } else if (msg.type === "code-channel:info") { + // Return viewer count for a code channel + const ch = msg.content; + const count = codeChannels[ch]?.size || 0; + send(pack("code-channel:info", { channel: ch, viewers: count }, id)); + } else if (msg.type === "slide" && msg.content?.codeChannel) { + // Handle slide broadcast (low-latency value updates, no state storage) + const targetChannel = msg.content.codeChannel; + + // Don't store slide updates as state (they're transient) + // Just broadcast immediately for low latency + if (codeChannels[targetChannel]) { + const slideMsg = pack("slide", msg.content, id); + subscribers(codeChannels[targetChannel], slideMsg); + } + } else if (msg.type === "code" && msg.content?.codeChannel) { + // Handle code broadcast to channel subscribers (for kidlisp.com pop-out sync) + const targetChannel = msg.content.codeChannel; + + // Store the latest state for late joiners + codeChannelState[targetChannel] = msg.content; + + if (codeChannels[targetChannel]) { + // Note: msg.content is already an object, pack() will JSON.stringify it + const codeMsg = pack("code", msg.content, id); + subscribers(codeChannels[targetChannel], codeMsg); + log(`๐Ÿ“ข Broadcast code to channel ${targetChannel} (${codeChannels[targetChannel].size} subscribers)`); + } + } else if (msg.type === "login") { + if (msg.content?.user?.sub) { + if (!clients[id]) clients[id] = { websocket: true }; + clients[id].user = msg.content.user.sub; + + // Fetch the user's handle from the API + const userSub = msg.content.user.sub; + log("๐Ÿ”‘ Login attempt for user:", userSub.substring(0, 20) + "...", "connection:", id); + + fetch(`https://aesthetic.computer/handle/${encodeURIComponent(userSub)}`) + .then(response => { + log("๐Ÿ“ก Handle API response status:", response.status, "for", userSub.substring(0, 20) + "..."); + return response.json(); + }) + .then(data => { + log("๐Ÿ“ฆ Handle API data:", JSON.stringify(data), "for connection:", id); + if (data.handle) { + clients[id].handle = data.handle; + log("โœ… User logged in:", data.handle, `(${userSub.substring(0, 12)}...)`, "connection:", id); + emitProfilePresence(data.handle, "login", ["handle", "online", "connections"]); + } else { + log("โš ๏ธ User logged in (no handle in response):", userSub.substring(0, 12), "..., connection:", id); + } + }) + .catch(err => { + log("โŒ Failed to fetch handle for:", userSub.substring(0, 20) + "...", "Error:", err.message); + }); + } + } else if (msg.type === "identify") { + // VSCode extension identifying itself + if (msg.content?.type === "vscode") { + vscodeClients.add(ws); + log("โœ… VSCode extension connected, conn:", id); + + // Send confirmation + ws.send(pack("identified", { type: "vscode", id }, id)); + } + } else if (msg.type === "dev:log") { + // ๐Ÿ“ก Remote log forwarding from connected devices (LAN Dev mode) + if (dev && msg.content) { + const { level, args, deviceName, connectionId, time, queued } = msg.content; + const client = clients[id]; + const deviceLabel = deviceName || client?.ip || `conn:${connectionId}`; + const levelEmoji = level === 'error' ? '๐Ÿ”ด' : level === 'warn' ? '๐ŸŸก' : '๐Ÿ”ต'; + const queuedTag = queued ? ' [Q]' : ''; + + // Format the log output + const timestamp = new Date(time).toLocaleTimeString(); + const argsStr = Array.isArray(args) ? args.join(' ') : String(args); + + console.log(`${levelEmoji} [${timestamp}] ${deviceLabel}${queuedTag}: ${argsStr}`); + } + } else if (msg.type === "location:broadcast") { /* + sub + .subscribe(`logout:broadcast:${msg.content.user.sub}`, () => { + ws.send(pack(`logout:broadcast:${msg.content.user.sub}`, true, id)); + }) + .then(() => { + log("๐Ÿƒ Subscribed to logout updates from:", msg.content.user.sub); + }) + .catch((err) => + error( + "๐Ÿƒ Could not unsubscribe from logout:broadcast for:", + msg.content.user.sub, + err, + ), + ); + */ + } else if (msg.type === "logout:broadcast:subscribe") { + /* + console.log("Logout broadcast:", msg.type, msg.content); + pub + .publish(`logout:broadcast:${msg.content.user.sub}`, "true") + .then((result) => { + console.log("๐Ÿƒ Logout broadcast successful for:", msg.content); + }) + .catch((error) => { + log("๐Ÿ™…โ€โ™€๏ธ Error publishing logout:", error); + }); + */ + } else if (msg.type === "location:broadcast") { + // Receive a slug location for this handle. + if (msg.content.slug !== "*keep-alive*") { + log("๐Ÿ—ผ Location:", msg.content.slug, "Handle:", msg.content.handle, "ID:", id); + } + + // Store handle and location for this client + if (!clients[id]) clients[id] = { websocket: true }; + const previousLocation = clients[id].location; + + // Extract user identity from message + if (msg.content?.user?.sub) { + clients[id].user = msg.content.user.sub; + } + + // Extract handle directly from message + if (msg.content.handle) { + clients[id].handle = msg.content.handle; + } + + // Extract and store location + if (msg.content.slug) { + // Don't overwrite location with keep-alive + if (msg.content.slug !== "*keep-alive*") { + clients[id].location = msg.content.slug; + log(`๐Ÿ“ Location updated for ${clients[id].handle || id}: "${msg.content.slug}"`); + if (previousLocation !== msg.content.slug) { + emitProfileActivity(msg.content.handle || clients[id].handle, { + type: "piece", + when: Date.now(), + label: `Piece ${msg.content.slug}`, + ref: msg.content.slug, + }); + } + } else { + log(`๐Ÿ’“ Keep-alive from ${clients[id].handle || id}, location unchanged`); + } + } + + emitProfilePresence( + msg.content.handle || clients[id].handle, + "location:broadcast", + ["online", "currentPiece", "connections"], + ); + + // Publish to redis... + pub + .publish("slug:" + msg.content.handle, msg.content.slug) + .then((result) => { + if (msg.content.slug !== "*keep-alive*") { + log( + "๐Ÿ› Slug succesfully published for:", + msg.content.handle, + msg.content.slug, + ); + } + }) + .catch((error) => { + log("๐Ÿ™…โ€โ™€๏ธ Error publishing slug:", error); + }); + + // TODO: - [] When a user is ghosted, then subscribe to their location + // updates. + // - [] And stop subscribing when they are unghosted. + } else if (msg.type === "dev-log" && dev) { + // Create device-specific log files and only notify in terminal + const timestamp = new Date().toISOString(); + const deviceId = `client-${id}`; + const logFileName = `${DEV_LOG_DIR}${deviceId}.log`; + + // Check if this is a new device + if (!deviceLogFiles.has(deviceId)) { + deviceLogFiles.set(deviceId, logFileName); + console.log(`๐Ÿ“ฑ New device logging: ${deviceId} -> ${logFileName}`); + console.log(` tail -f ${logFileName}`); + } + + // Write to device-specific log file + const logEntry = `[${timestamp}] ${msg.content.level || 'LOG'}: ${msg.content.message}\n`; + + try { + fs.appendFileSync(logFileName, logEntry); + } catch (error) { + console.error(`Failed to write to ${logFileName}:`, error); + } + } else { + // ๐Ÿ—บ๏ธ World Messages + // TODO: Should all messages be prefixed with their piece? + + // Filter for `world:${piece}:${label}` type messages. + if (msg.type.startsWith("world:")) { + const parsed = msg.type.split(":"); + const piece = parsed[1]; + const label = parsed.pop(); + const worldHandle = resolveProfileHandle(id, piece, msg.content?.handle); + + // TODO: Store client position on disconnect, based on their handle. + + if (label === "show") { + // Store any existing show picture in clients list. + worldClients[piece][id].showing = msg.content; + emitProfileActivity(worldHandle, { + type: "show", + when: Date.now(), + label: `Showing in ${piece}`, + piece, + ref: piece, + }); + emitProfilePresence(worldHandle, `world:${piece}:show`, ["world", "showing"]); + } + + if (label === "hide") { + // Store any existing show picture in clients list. + worldClients[piece][id].showing = null; + emitProfileActivity(worldHandle, { + type: "hide", + when: Date.now(), + label: `Hide in ${piece}`, + piece, + ref: piece, + }); + emitProfilePresence(worldHandle, `world:${piece}:hide`, ["world", "showing"]); + } + + // Intercept chats and filter them (skip for laer-klokken). + if (label === "write") { + if (piece !== "laer-klokken") msg.content = filter(msg.content); + const chatText = + typeof msg.content === "string" ? msg.content : msg.content?.text; + if (chatText) { + emitProfileActivity(worldHandle, { + type: "chat", + when: Date.now(), + label: `Chat ${piece}: ${truncateProfileText(chatText, 80)}`, + piece, + ref: piece, + text: chatText, + }); + emitProfileCountDelta(worldHandle, { chats: 1 }); + } + } + + if (label === "join") { + if (!worldClients[piece]) worldClients[piece] = {}; + + // Check to see if the client handle matches and a connection can + // be reassociated. + + let pickedUpConnection = false; + keys(worldClients[piece]).forEach((clientID) => { + // TODO: Break out of this loop early. + const client = worldClients[piece][clientID]; + if ( + client["handle"].startsWith("@") && + client["handle"] === msg.content.handle && + client.ghosted + ) { + // log("๐Ÿ‘ป Ghosted?", client); + + log( + "๐Ÿ‘ป Unghosting:", + msg.content.handle, + "old id:", + clientID, + "new id:", + id, + ); + pickedUpConnection = true; + + client.ghosted = false; + + sub + .unsubscribe("slug:" + msg.content.handle) + .then(() => { + log("๐Ÿ› Unsubscribed from slug for:", msg.content.handle); + }) + .catch((err) => { + error( + "๐Ÿ› Could not unsubscribe from slug for:", + msg.content.handle, + err, + ); + }); + + delete worldClients[piece][clientID]; + + ws.send(pack(`world:${piece}:list`, worldClients[piece], id)); + + // Replace the old client with the new data. + worldClients[piece][id] = { ...msg.content }; + } + }); + + if (!pickedUpConnection) + ws.send(pack(`world:${piece}:list`, worldClients[piece], id)); + + // โค๏ธโ€๐Ÿ”ฅ TODO: No need to send the current user back via `list` here. + if (!pickedUpConnection) worldClients[piece][id] = { ...msg.content }; + + // ^ Send existing list to just this user. + + others(JSON.stringify(msg)); // Alert everyone else about the join. + + log("๐Ÿงฉ Clients in piece:", piece, worldClients[piece]); + emitProfileActivity(worldHandle, { + type: "join", + when: Date.now(), + label: `Joined ${piece}`, + piece, + ref: piece, + }); + emitProfilePresence(worldHandle, `world:${piece}:join`, ["world", "connections"]); + return; + } else if (label === "move") { + // log("๐Ÿšถโ€โ™‚๏ธ", piece, msg.content); + if (typeof worldClients?.[piece]?.[id] === "object") + worldClients[piece][id].pos = msg.content.pos; + } else { + log(`${label}:`, msg.content); + } + + if (label === "persist") { + log("๐Ÿงฎ Persisting this client...", msg.content); + } + + // All world: messages are only broadcast to "others", with the + // exception of "write" with relays the filtered message back: + if (label === "write") { + everyone(JSON.stringify(msg)); + } else { + others(JSON.stringify(msg)); + } + return; + } + + // ๐ŸŽฎ 1v1 game position updates should only go to others (not back to sender) + if (msg.type === "1v1:move") { + // Log occasionally in production for debugging (1 in 100 messages) + if (Math.random() < 0.01) { + log(`๐ŸŽฎ 1v1:move relay: ${msg.content?.handle || id} -> ${wss.clients.size - 1} others`); + } + others(JSON.stringify(msg)); + return; + } + + // ๐ŸŽพ Squash game position updates โ€” relay to others only (not back to sender) + if (msg.type === "squash:move") { + others(JSON.stringify(msg)); + return; + } + + // ๐Ÿ”Š Audio data from kidlisp.com โ€” relay only to code-channel subscribers + if (msg.type === "audio" && msg.content?.codeChannel) { + const ch = msg.content.codeChannel; + if (codeChannels[ch]) { + subscribers(codeChannels[ch], pack("audio", msg.content, id)); + } + return; + } + + // ๐ŸŽฎ 1v1 join/state messages - log and relay to everyone + if (msg.type === "1v1:join" || msg.type === "1v1:state") { + log(`๐ŸŽฎ ${msg.type}: ${msg.content?.handle || id} -> all ${wss.clients.size} clients`); + } + + // ๐ŸŽฏ Duel messages โ€” routed to DuelManager (server-authoritative) + if (msg.type === "duel:join") { + const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; + if (handle) duelManager.playerJoin(handle, id); + return; + } + if (msg.type === "duel:leave") { + const handle = typeof msg.content === "string" ? JSON.parse(msg.content).handle : msg.content?.handle; + if (handle) duelManager.playerLeave(handle); + return; + } + if (msg.type === "duel:ping") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) duelManager.handlePing(parsed.handle, parsed.ts, id); + return; + } + if (msg.type === "duel:clientlog") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + console.log(`๐ŸŽฏ [CLIENT ${parsed?.handle}] ${parsed?.msg}`, parsed?.bullets?.length > 0 ? JSON.stringify(parsed.bullets) : ""); + return; + } + if (msg.type === "duel:input") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) duelManager.receiveInput(parsed.handle, parsed); + return; + } + + // ๐ŸŸ๏ธ Arena messages โ€” routed to ArenaManager (server-authoritative) + if (msg.type === "arena:hello") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) { + // Ensure clients[id].handle is set so the WS-close handler can + // look it up and call playerLeave. Without this, probes + any + // client that hasn't sent a chat login message would leak forever. + if (!clients[id]) clients[id] = {}; + clients[id].handle = parsed.handle; + arenaManager.playerJoin(parsed.handle, id, { probe: !!parsed.probe }); + } + return; + } + if (msg.type === "arena:bye") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) arenaManager.playerLeave(parsed.handle); + return; + } + if (msg.type === "arena:cmd") { + // WS fallback path; the fast path is the UDP channel.on("arena:cmd", ...) handler. + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) arenaManager.receiveCmd(parsed.handle, parsed); + return; + } + if (msg.type === "arena:ping") { + const parsed = typeof msg.content === "string" ? JSON.parse(msg.content) : msg.content; + if (parsed?.handle) arenaManager.handlePing(parsed.handle, parsed.ts, id); + return; + } + + everyone(JSON.stringify(msg)); // Relay any other message to every user. + } + }); + + // More info: https://stackoverflow.com/a/49791634/8146077 + ws.on("close", () => { + log("๐Ÿšช Someone left:", id, "Online:", wss.clients.size, "๐Ÿซ‚"); + const departingHandle = normalizeProfileHandle(clients?.[id]?.handle); + if (departingHandle) duelManager.playerLeave(departingHandle); + // Arena uses the raw handle (matches arena:hello), not the @-normalized form. + // Pass the closing wsId so a quick reload-race doesn't delete the + // freshly-rebound player (the new hello will have set a different wsId). + const rawDepartingHandle = clients?.[id]?.handle; + if (rawDepartingHandle) arenaManager.playerLeave(rawDepartingHandle, id); + removeNotepatMidiSubscriber(id); + + // Remove from VSCode clients if present + vscodeClients.delete(ws); + + // Remove from DAW devices if present + if (dawDevices.has(id)) { + dawDevices.delete(id); + log(`๐ŸŽน DAW device disconnected: ${id} (remaining: ${dawDevices.size})`); + } + if (dawIDEs.has(id)) { + dawIDEs.delete(id); + log(`๐ŸŽน DAW IDE disconnected: ${id}`); + } + + // Delete the user from the worldClients pieces index. + // keys(worldClients).forEach((piece) => { + // delete worldClients[piece][id]; + // if (keys(worldClients[piece]).length === 0) + // delete worldClients[piece]; + // }); + + if (clients[id]?.user) { + const userSub = clients[id].user; + sub + .unsubscribe("logout:broadcast:" + userSub) + .then(() => { + log("๐Ÿƒ Unsubscribed from logout:broadcast for:", userSub); + }) + .catch((err) => { + error( + "๐Ÿƒ Could not unsubscribe from logout:broadcast for:", + userSub, + err, + ); + }); + } + + // Send a message to everyone else on the server that this client left. + + let ghosted = false; + + keys(worldClients).forEach((piece) => { + if (worldClients[piece][id]) { + // Turn this client into a ghost, unless it's the last one in the + // world region. + if ( + worldClients[piece][id].handle.startsWith("@") && + keys(worldClients[piece]).length > 1 + ) { + const handle = worldClients[piece][id].handle; + log("๐Ÿ‘ป Ghosted:", handle); + log("World clients after ghosting:", worldClients[piece]); + worldClients[piece][id].ghost = true; + ghosted = true; + + function kick() { + log("๐Ÿ‘ข Kicked:", handle, id); + clearTimeout(kickTimer); + sub + .unsubscribe("slug:" + handle) + .then(() => { + log("๐Ÿ› Unsubscribed from slug for:", handle); + }) + .catch((err) => { + error("๐Ÿ› Could not unsubscribe from slug for:", handle, err); + }); + // Delete the user from the worldClients pieces index. + delete worldClients[piece][id]; + if (keys(worldClients[piece]).length === 0) + delete worldClients[piece]; + everyone(pack(`world:${piece}:kick`, {}, id)); // Kick this ghost. + } + + let kickTimer = setTimeout(kick, 5000); + + const worlds = ["field", "horizon"]; // Whitelist for worlds... + // This could eventually be communicated based on a parameter in + // the subscription? 24.03.09.15.05 + + // Subscribe to slug updates from redis. + sub + .subscribe("slug:" + handle, (slug) => { + if (slug !== "*keep-alive*") { + log(`๐Ÿ› ${handle} is now in:`, slug); + if (!worlds.includes(slug)) + everyone(pack(`world:${piece}:slug`, { handle, slug }, id)); + } + + if (worlds.includes(slug)) { + kick(); + } else { + clearTimeout(kickTimer); + kickTimer = setTimeout(kick, 5000); + } + // Whitelist slugs here + }) + .then(() => { + log("๐Ÿ› Subscribed to slug updates from:", handle); + }) + .catch((err) => + error("๐Ÿ› Could not subscribe to slug for:", handle, err), + ); + + // Send a message to everyone on the server that this client is a ghost. + everyone(pack(`world:${piece}:ghost`, {}, id)); + } else { + // Delete the user from the worldClients pieces index. + delete worldClients[piece][id]; + if (keys(worldClients[piece]).length === 0) + delete worldClients[piece]; + } + } + }); + + // Send a message to everyone else on the server that this client left. + if (!ghosted) everyone(pack("left", { count: wss.clients.size }, id)); + + // Delete from the connection index. + delete connections[id]; + + // Clean up client record if no longer connected via any protocol + if (clients[id]) { + clients[id].websocket = false; + // If also not connected via UDP, delete the client record entirely + if (!udpChannels[id]) { + delete clients[id]; + } + } + + // Clear out the codeChannel if the last user disconnects from it. + if (codeChannel !== undefined) { + codeChannels[codeChannel]?.delete(id); + if (codeChannels[codeChannel]?.size === 0) { + delete codeChannels[codeChannel]; + delete codeChannelState[codeChannel]; // Clean up stored state too + log(`๐Ÿ—‘๏ธ Cleaned up empty channel: ${codeChannel}`); + } + } + + if (departingHandle) { + emitProfilePresence(departingHandle, "disconnect", ["online", "connections"]); + emitProfileActivity(departingHandle, { + type: "presence", + when: Date.now(), + label: "Disconnected", + }); + } + }); +}); + +// Sends a message to all connected clients. +function everyone(string) { + wss.clients.forEach((c) => { + if (c?.readyState === WebSocket.OPEN) c.send(string); + }); +} + +// Sends a message to a particular set of client ids on +// this instance that are part of the `subs` Set. +function subscribers(subs, msg) { + subs.forEach((connectionId) => { + connections[connectionId]?.send(msg); + }); +} + +// ๐ŸŽฏ Wire DuelManager send functions +duelManager.setSendFunctions({ + sendUDP: (channelId, event, data) => { + const entry = udpChannels[channelId]; + if (entry?.channel?.webrtcConnection?.state === "open") { + try { entry.channel.emit(event, data); return true; } catch {} + } + return false; // Signal failure so caller can fall back to WS + }, + sendWS: (wsId, type, content) => { + connections[wsId]?.send(pack(type, JSON.stringify(content), "duel")); + }, + broadcastWS: (type, content) => { + everyone(pack(type, JSON.stringify(content), "duel")); + }, + resolveUdpForHandle: (handle) => { + for (const [id, client] of Object.entries(clients)) { + if (client.handle === handle && udpChannels[id]) return id; + } + return null; + }, +}); + +// ๐ŸŸ๏ธ Wire ArenaManager send functions (same shape as DuelManager; separate source tag). +arenaManager.setSendFunctions({ + sendUDP: (channelId, event, data) => { + const entry = udpChannels[channelId]; + if (entry?.channel?.webrtcConnection?.state === "open") { + try { entry.channel.emit(event, data); return true; } catch {} + } + return false; + }, + sendWS: (wsId, type, content) => { + connections[wsId]?.send(pack(type, JSON.stringify(content), "arena")); + }, + broadcastWS: (type, content) => { + everyone(pack(type, JSON.stringify(content), "arena")); + }, + resolveUdpForHandle: (handle) => { + for (const [id, client] of Object.entries(clients)) { + if (client.handle === handle && udpChannels[id]) return id; + } + return null; + }, + // Used to distinguish reconnect (old ws dead โ†’ silent refresh) from a + // real takeover (old ws still alive โ†’ demote to spectator). + isLive: (wsId) => connections[wsId]?.readyState === WebSocket.OPEN, +}); +// #endregion + +// *** Status WebSocket Stream *** +// Track status dashboard clients (separate from game clients) +const statusClients = new Set(); +// Track targeted profile subscribers by normalized handle key (`@name`) +const profileStreamClients = new Map(); +const profileLastSeen = new Map(); + +// *** VSCode Extension Clients *** +// Track VSCode extension clients for direct jump message routing +const vscodeClients = new Set(); + +function normalizeProfileHandle(handle) { + if (!handle) return null; + const raw = `${handle}`.trim(); + if (!raw) return null; + return `@${raw.replace(/^@+/, "").toLowerCase()}`; +} + +function normalizeMidiHandle(handle) { + const normalized = normalizeProfileHandle(handle); + return normalized ? normalized.slice(1) : ""; +} + +function notepatMidiSourceKey(handle, machineId) { + const handleKey = normalizeProfileHandle(handle) || "@unknown"; + const machineKey = `${machineId || "unknown"}`.trim() || "unknown"; + return `${handleKey}:${machineKey}`; +} + +function listNotepatMidiSources() { + return [...notepatMidiSources.values()] + .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)) + .map((source) => ({ + handle: source.handle || null, + machineId: source.machineId, + piece: source.piece || "notepat", + lastSeen: source.lastSeen || 0, + lastEvent: source.lastEvent || null, + })); +} + +function sendNotepatMidiSources(ws) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + ws.send(pack("notepat:midi:sources", { sources: listNotepatMidiSources() }, "midi-relay")); + } catch (err) { + error("๐ŸŽน Failed to send notepat midi sources:", err); + } +} + +function removeNotepatMidiSubscriber(id) { + if (id === undefined || id === null) return; + notepatMidiSubscribers.delete(id); +} + +function addNotepatMidiSubscriber(id, ws, filter = {}) { + if (id === undefined || id === null || !ws) return; + + notepatMidiSubscribers.set(id, { + ws, + all: filter.all === true, + handle: normalizeMidiHandle(filter.handle), + machineId: filter.machineId ? `${filter.machineId}`.trim() : "", + }); + + if (ws.readyState === WebSocket.OPEN) { + ws.send(pack("notepat:midi:subscribed", { + all: filter.all === true, + handle: normalizeMidiHandle(filter.handle) || null, + machineId: filter.machineId ? `${filter.machineId}`.trim() : null, + }, "midi-relay")); + } + + sendNotepatMidiSources(ws); +} + +function broadcastNotepatMidiSources() { + for (const [id, sub] of notepatMidiSubscribers) { + if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { + notepatMidiSubscribers.delete(id); + continue; + } + sendNotepatMidiSources(sub.ws); + } +} + +function notepatMidiSubscriberMatches(sub, event) { + if (!sub) return false; + if (sub.all) return true; + + const eventHandle = normalizeMidiHandle(event?.handle); + const eventMachine = event?.machineId ? `${event.machineId}`.trim() : ""; + + if (sub.handle && sub.handle !== eventHandle) return false; + if (sub.machineId && sub.machineId !== eventMachine) return false; + + return !!(sub.handle || sub.machineId); +} + +function broadcastNotepatMidiEvent(event) { + for (const [id, sub] of notepatMidiSubscribers) { + if (!sub?.ws || sub.ws.readyState !== WebSocket.OPEN) { + notepatMidiSubscribers.delete(id); + continue; + } + if (!notepatMidiSubscriberMatches(sub, event)) continue; + try { + sub.ws.send(pack("notepat:midi", event, "midi-relay")); + } catch (err) { + error("๐ŸŽน Failed to fan out notepat midi event:", err); + } + } + // UDP fan-out. Same filter model, emitted on the geckos channel. M4L + // notepat-remote devices care about this path for sub-frame latency โ€” + // the WS path is ~5-15 ms slower end-to-end on a typical home network. + for (const [id, sub] of notepatMidiUdpSubscribers) { + if (!sub?.channel || sub.channel.webrtcConnection?.state !== "open") { + notepatMidiUdpSubscribers.delete(id); + continue; + } + if (!notepatMidiSubscriberMatches(sub, event)) continue; + try { + sub.channel.emit("notepat:midi", event); + } catch (err) { + error("๐ŸŽน UDP fan-out failed:", err); + } + } +} + +function upsertNotepatMidiSource({ handle, machineId, piece, lastEvent, ts, address, port }) { + const cleanHandle = normalizeMidiHandle(handle); + const cleanMachineId = `${machineId || "unknown"}`.trim() || "unknown"; + const key = notepatMidiSourceKey(cleanHandle, cleanMachineId); + const previous = notepatMidiSources.get(key); + const next = { + handle: cleanHandle || null, + machineId: cleanMachineId, + piece: piece || "notepat", + lastSeen: ts || Date.now(), + lastEvent: lastEvent || previous?.lastEvent || null, + address: address || previous?.address || null, + port: port || previous?.port || null, + }; + + notepatMidiSources.set(key, next); + + if (!previous) { + log(`๐ŸŽน Notepat MIDI source online: ${next.handle ? "@" + next.handle : "@unknown"} ${next.machineId}`); + } + + if ( + !previous || + previous.handle !== next.handle || + previous.machineId !== next.machineId || + previous.piece !== next.piece + ) { + broadcastNotepatMidiSources(); + } + + return next; +} + +function compactProfileText(value) { + return `${value || ""}`.replace(/\s+/g, " ").trim(); +} + +function truncateProfileText(value, max = 100) { + const text = compactProfileText(value); + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 3))}...`; +} + +function getProfilePresence(handleKey) { + if (!handleKey) return null; + const clientsForStatus = getClientStatus(); + const matched = clientsForStatus.find( + (client) => normalizeProfileHandle(client?.handle) === handleKey, + ); + + if (!matched) { + return { + online: false, + currentPiece: null, + worldPiece: null, + showing: null, + connections: { websocket: 0, udp: 0, total: 0 }, + pingMs: null, + lastSeenAt: profileLastSeen.get(handleKey) || null, + }; + } + + const now = Date.now(); + profileLastSeen.set(handleKey, now); + + const world = matched?.websocket?.worlds?.[0] || null; + + return { + online: true, + currentPiece: matched.location || null, + worldPiece: world?.piece || null, + showing: world?.showing || null, + connections: matched.connectionCount || { websocket: 0, udp: 0, total: 0 }, + pingMs: matched?.websocket?.ping || null, + lastSeenAt: now, + }; +} + +function sendProfileStream(ws, type, data) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + ws.send(JSON.stringify({ type, data, timestamp: Date.now() })); + } catch (err) { + error("๐Ÿ‘ค Failed to send profile stream event:", err); + } +} + +function broadcastProfileStream(handleKey, type, data) { + const subs = profileStreamClients.get(handleKey); + if (!subs || subs.size === 0) return; + + const stale = []; + subs.forEach((ws) => { + if (ws.readyState !== WebSocket.OPEN) { + stale.push(ws); + return; + } + sendProfileStream(ws, type, data); + }); + + stale.forEach((ws) => subs.delete(ws)); + if (subs.size === 0) profileStreamClients.delete(handleKey); +} + +function addProfileStreamClient(ws, handle) { + const handleKey = normalizeProfileHandle(handle); + if (!handleKey) return null; + + if (!profileStreamClients.has(handleKey)) { + profileStreamClients.set(handleKey, new Set()); + } + + profileStreamClients.get(handleKey).add(ws); + ws.profileHandleKey = handleKey; + + const presence = getProfilePresence(handleKey); + sendProfileStream(ws, "profile:snapshot", { + handle: handleKey, + presence, + }); + sendProfileStream(ws, "counts:update", { + handle: handleKey, + counts: { + online: presence?.online ? 1 : 0, + connections: presence?.connections?.total || 0, + }, + }); + + return handleKey; +} + +function removeProfileStreamClient(ws) { + const handleKey = ws?.profileHandleKey; + if (!handleKey) return; + + const subs = profileStreamClients.get(handleKey); + if (!subs) { + ws.profileHandleKey = null; + return; + } + + subs.delete(ws); + if (subs.size === 0) profileStreamClients.delete(handleKey); + ws.profileHandleKey = null; +} + +function emitProfilePresence(handle, reason = "update", changed = []) { + const handleKey = normalizeProfileHandle(handle); + if (!handleKey) return; + + const presence = getProfilePresence(handleKey); + broadcastProfileStream(handleKey, "presence:update", { + handle: handleKey, + reason, + changed, + presence, + }); + broadcastProfileStream(handleKey, "counts:update", { + handle: handleKey, + counts: { + online: presence?.online ? 1 : 0, + connections: presence?.connections?.total || 0, + }, + }); +} + +function emitProfileCountDelta(handle, delta = {}) { + const handleKey = normalizeProfileHandle(handle); + if (!handleKey) return; + if (!delta || typeof delta !== "object") return; + + const cleanDelta = {}; + for (const [key, value] of Object.entries(delta)) { + const amount = Number(value); + if (!Number.isFinite(amount) || amount === 0) continue; + cleanDelta[key] = amount; + } + if (Object.keys(cleanDelta).length === 0) return; + + broadcastProfileStream(handleKey, "counts:delta", { + handle: handleKey, + delta: cleanDelta, + }); +} + +function emitProfileActivity(handle, event = {}) { + const handleKey = normalizeProfileHandle(handle); + if (!handleKey) return; + + const label = truncateProfileText( + event.label || event.text || event.type || "event", + 120, + ); + if (!label) return; + + broadcastProfileStream(handleKey, "activity:append", { + handle: handleKey, + event: { + type: event.type || "event", + when: event.when || Date.now(), + label, + ref: event.ref || null, + piece: event.piece || null, + }, + }); +} + +function resolveProfileHandle(id, piece, fromMessage) { + return ( + normalizeProfileHandle(fromMessage) || + normalizeProfileHandle(clients?.[id]?.handle) || + normalizeProfileHandle(worldClients?.[piece]?.[id]?.handle) + ); +} + +chatManager.setActivityEmitter((payload = {}) => { + const handle = payload.handle; + if (payload.event) emitProfileActivity(handle, payload.event); + if (payload.countsDelta) emitProfileCountDelta(handle, payload.countsDelta); +}); + +// Broadcast status updates every 2 seconds +setInterval(() => { + if (statusClients.size > 0) { + const status = getFullStatus(); + statusClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + try { + client.send(JSON.stringify({ type: 'status', data: status })); + } catch (err) { + error('๐Ÿ“Š Failed to send status update:', err); + } + } + }); + } +}, 2000); + +// Broadcast targeted profile heartbeat updates every 2 seconds +setInterval(() => { + if (profileStreamClients.size === 0) return; + + for (const handleKey of profileStreamClients.keys()) { + const presence = getProfilePresence(handleKey); + broadcastProfileStream(handleKey, "presence:update", { + handle: handleKey, + reason: "heartbeat", + changed: [], + presence, + }); + } +}, 2000); + +// ๐Ÿงš UDP Server (using Twilio ICE servers) +// #endregion udp + +// Note: This currently works off of a monolith via `udp.aesthetic.computer` +// as the ports are blocked on jamsocket. + +// geckos.io is imported at top and initialized before server.listen() + +io.onConnection((channel) => { + // Track this UDP channel + udpChannels[channel.id] = { + connectedAt: Date.now(), + state: channel.webrtcConnection.state, + user: null, + handle: null, + channel: channel, // Store reference for targeted sends + }; + + // Get IP address from channel + const udpIp = channel.userData?.address || channel.remoteAddress || null; + + log(`๐Ÿฉฐ UDP ${channel.id} connected from:`, udpIp || 'unknown'); + + // Initialize client record with IP + if (!clients[channel.id]) clients[channel.id] = { udp: true }; + if (udpIp) { + const cleanIp = udpIp.replace('::ffff:', ''); + clients[channel.id].ip = cleanIp; + + // Get geolocation for UDP client + const geo = geoip.lookup(cleanIp); + if (geo) { + clients[channel.id].geo = { + country: geo.country, + region: geo.region, + city: geo.city, + timezone: geo.timezone, + ll: geo.ll + }; + log(`๐ŸŒ UDP ${channel.id} geolocation:`, geo.city || geo.country); + } + } + + // Set a timeout to warn about missing identity + setTimeout(() => { + if (!clients[channel.id]?.user && !clients[channel.id]?.handle) { + log(`โš ๏ธ UDP ${channel.id} has been connected for 10s but hasn't sent identity message`); + } + }, 10000); + + // Handle identity message + channel.on("udp:identity", (data) => { + try { + const identity = JSON.parse(data); + log(`๐Ÿฉฐ UDP ${channel.id} sent identity:`, JSON.stringify(identity).substring(0, 100)); + + // Initialize client record if needed + if (!clients[channel.id]) clients[channel.id] = { udp: true }; + + // Extract user identity + if (identity.user?.sub) { + clients[channel.id].user = identity.user.sub; + log(`๐Ÿฉฐ UDP ${channel.id} user:`, identity.user.sub.substring(0, 20) + "..."); + } + + // Extract handle directly from identity message + if (identity.handle) { + clients[channel.id].handle = identity.handle; + log(`โœ… UDP ${channel.id} handle: "${identity.handle}"`); + // Resolve UDP channel for duel if this handle is in a duel + duelManager.resolveUdpChannel(identity.handle, channel.id); + // Resolve UDP channel for arena if this handle is in the arena + arenaManager.resolveUdpChannel(identity.handle, channel.id); + } + } catch (e) { + error(`๐Ÿฉฐ Failed to parse identity for ${channel.id}:`, e); + } + }); + + channel.onDisconnect(() => { + log(`๐Ÿฉฐ ${channel.id} got disconnected`); + delete udpChannels[channel.id]; + fairyThrottle.delete(channel.id); + notepatMidiUdpSubscribers.delete(channel.id); + + // Clean up client record if no longer connected via any protocol + if (clients[channel.id]) { + clients[channel.id].udp = false; + // If also not connected via WebSocket, delete the client record entirely + if (!connections[channel.id]) { + delete clients[channel.id]; + } + } + + channel.close(); + }); + + // ๐ŸŽน Notepat MIDI relay over UDP. Same subscribe/unsubscribe model as the + // WS path (cross-session, filter on handle/machineId or all:true). Events + // fan out via notepatMidiUdpSubscribers in broadcastNotepatMidiEvent. + channel.on("notepat:midi:subscribe", (data) => { + let filter = {}; + try { filter = typeof data === "string" ? JSON.parse(data) : (data || {}); } catch {} + // Optional: wrap in `{ filter: {...} }` or pass fields directly โ€” accept both. + if (filter.filter) filter = filter.filter; + notepatMidiUdpSubscribers.set(channel.id, { + channel, + all: filter.all === true, + handle: normalizeMidiHandle(filter.handle), + machineId: filter.machineId ? `${filter.machineId}`.trim() : "", + }); + try { channel.emit("notepat:midi:subscribed", { + all: filter.all === true, + handle: normalizeMidiHandle(filter.handle) || null, + machineId: filter.machineId ? `${filter.machineId}`.trim() : null, + }); } catch {} + log(`๐ŸŽน UDP ${channel.id} subscribed to notepat:midi (all=${filter.all === true})`); + }); + + channel.on("notepat:midi:unsubscribe", () => { + notepatMidiUdpSubscribers.delete(channel.id); + try { channel.emit("notepat:midi:unsubscribed", true); } catch {} + }); + + // ๐ŸŽน Browser-side notepat (notepat.com on web) publishes MIDI events + // via the geckos UDP channel for sub-frame latency. Mirror of the WS + // `notepat:midi:publish` handler โ€” same shape, same broadcaster. + const handleUdpNotepatPublish = (data, isHeartbeat) => { + let payload = {}; + try { payload = typeof data === "string" ? JSON.parse(data) : (data || {}); } catch {} + const now = Date.now(); + const source = upsertNotepatMidiSource({ + handle: payload.handle, + machineId: payload.machineId, + piece: payload.piece || "notepat", + lastEvent: isHeartbeat ? "heartbeat" : payload.event, + ts: now, + }); + if (isHeartbeat) return; + if (!source.handle && !source.machineId) return; + const rawNote = Number(payload.note); + const rawVelocity = Number(payload.velocity); + const rawChannel = Number(payload.channel); + if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) return; + let event = payload.event === "note_off" ? "note_off" : "note_on"; + const note = Math.max(0, Math.min(127, Math.round(rawNote))); + const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); + const channel2 = Math.max(0, Math.min(15, Math.round(rawChannel))); + if (event === "note_on" && velocity === 0) event = "note_off"; + broadcastNotepatMidiEvent({ + type: "notepat:midi", + event, + note, + velocity, + channel: channel2, + handle: source.handle, + machineId: source.machineId, + piece: source.piece || "notepat", + ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, + }); + }; + channel.on("notepat:midi", (data) => handleUdpNotepatPublish(data, false)); + channel.on("notepat:midi:heartbeat", (data) => handleUdpNotepatPublish(data, true)); + + // ๐Ÿ’Ž TODO: Make these channel names programmable somehow? 24.12.08.04.12 + + channel.on("tv", (data) => { + if (channel.webrtcConnection.state === "open") { + try { + channel.room.emit("tv", data); + } catch (err) { + console.warn("Broadcast error:", err); + } + } else { + console.log(channel.webrtcConnection.state); + } + }); + + // Just for testing via the aesthetic `udp` piece for now. + channel.on("fairy:point", (data) => { + // See docs here: https://github.com/geckosio/geckos.io#reliable-messages + // TODO: - [] Learn about the differences between channels and rooms. + + // log(`๐Ÿฉฐ fairy:point - ${data}`); + if (channel.webrtcConnection.state === "open") { + try { + channel.broadcast.emit("fairy:point", data); + // ^ emit the to all channels in the same room except the sender + + // Bridge to raw UDP clients (native bare-metal) + try { + const parsed = typeof data === "string" ? JSON.parse(data) : data; + const x = parseFloat(parsed.x) || 0; + const y = parseFloat(parsed.y) || 0; + const handle = parsed.handle || ""; + const hlen = Buffer.byteLength(handle, "utf8"); + const pkt = Buffer.alloc(10 + hlen); + pkt[0] = 0x02; // fairy broadcast + pkt.writeFloatLE(x, 1); + pkt.writeFloatLE(y, 5); + pkt[9] = hlen; + pkt.write(handle, 10, "utf8"); + for (const [, client] of udpClients) { + udpRelay.send(pkt, client.port, client.address); + } + } catch (e) { /* ignore bridge errors */ } + + // Publish to Redis for silo firehose visualization (throttled ~10Hz) + const now = Date.now(); + const last = fairyThrottle.get(channel.id) || 0; + if (now - last >= FAIRY_THROTTLE_MS) { + fairyThrottle.set(channel.id, now); + pub.publish("fairy:point", data).catch(() => {}); + } + } catch (err) { + console.warn("Broadcast error:", err); + } + } else { + console.log(channel.webrtcConnection.state); + } + }); + + // ๐ŸŽฎ 1v1 FPS game position updates over UDP (low latency) + channel.on("1v1:move", (data) => { + if (channel.webrtcConnection.state === "open") { + try { + // Log occasionally for production debugging (1 in 100) + if (Math.random() < 0.01) { + const parsed = typeof data === 'string' ? JSON.parse(data) : data; + log(`๐Ÿฉฐ UDP 1v1:move: ${parsed?.handle || channel.id} broadcasting`); + } + // Broadcast position to all other players except sender + channel.broadcast.emit("1v1:move", data); + } catch (err) { + console.warn("1v1:move broadcast error:", err); + } + } + }); + + // ๐ŸŽพ Squash game position updates over UDP (low latency) + channel.on("squash:move", (data) => { + if (channel.webrtcConnection.state === "open") { + try { + channel.broadcast.emit("squash:move", data); + } catch (err) { + console.warn("squash:move broadcast error:", err); + } + } + }); + + // ๐ŸŽฏ Duel input over UDP (server-authoritative โ€” NOT relayed, fed to DuelManager) + channel.on("duel:input", (data) => { + if (channel.webrtcConnection.state === "open") { + try { + const parsed = typeof data === "string" ? JSON.parse(data) : data; + // Resolve handle from channel identity OR from message payload + const handle = clients[channel.id]?.handle || parsed.handle; + if (handle) { + duelManager.receiveInput(handle, parsed); + // Also resolve UDP channel if not yet linked + if (!clients[channel.id]?.handle && parsed.handle) { + duelManager.resolveUdpChannel(parsed.handle, channel.id); + } + } + } catch (err) { + console.warn("duel:input error:", err); + } + } + }); + + // ๐ŸŸ๏ธ Arena usercmd over UDP (fast path; WS is the fallback) + channel.on("arena:cmd", (data) => { + if (channel.webrtcConnection.state !== "open") return; + try { + const parsed = typeof data === "string" ? JSON.parse(data) : data; + const handle = clients[channel.id]?.handle || parsed.handle; + if (!handle) return; + arenaManager.receiveCmd(handle, parsed); + if (!clients[channel.id]?.handle && parsed.handle) { + arenaManager.resolveUdpChannel(parsed.handle, channel.id); + } + } catch (err) { + console.warn("arena:cmd error:", err); + } + }); + + // ๐ŸŽš๏ธ Slide mode: real-time code value updates via UDP (lowest latency) + channel.on("slide:code", (data) => { + if (channel.webrtcConnection.state === "open") { + try { + // Broadcast to all including sender (room.emit) for sync + channel.room.emit("slide:code", data); + } catch (err) { + console.warn("slide:code broadcast error:", err); + } + } + }); + + // ๐Ÿ”Š Audio: real-time audio analysis data via UDP (lowest latency) + channel.on("udp:audio", (data) => { + if (channel.webrtcConnection.state === "open") { + try { + channel.room.emit("udp:audio", data); + } catch (err) { + console.warn("udp:audio broadcast error:", err); + } + } + }); +}); + +// #endregion + +// --------------------------------------------------------------------------- +// ๐Ÿงš Raw UDP fairy relay (port 10010) โ€” for native bare-metal clients +// Binary packet format: +// [1 byte type] [4 float x LE] [4 float y LE] [1 handle_len] [N handle] +// Type 0x01 = clientโ†’server, 0x02 = serverโ†’client broadcast +// --------------------------------------------------------------------------- +const UDP_FAIRY_PORT = 10010; + +function handleNotepatMidiUdpPacket(payload, rinfo) { + if (!payload || (payload.type !== "notepat:midi" && payload.type !== "notepat:midi:heartbeat")) { + return false; + } + + const now = Date.now(); + const source = upsertNotepatMidiSource({ + handle: payload.handle, + machineId: payload.machineId, + piece: payload.piece || "notepat", + lastEvent: payload.type === "notepat:midi" ? payload.event : "heartbeat", + ts: now, + address: rinfo.address, + port: rinfo.port, + }); + + if (!source.handle && !source.machineId) { + return true; + } + + if (payload.type === "notepat:midi:heartbeat") { + return true; + } + + const rawNote = Number(payload.note); + const rawVelocity = Number(payload.velocity); + const rawChannel = Number(payload.channel); + if (!Number.isFinite(rawNote) || !Number.isFinite(rawVelocity) || !Number.isFinite(rawChannel)) { + log("๐ŸŽน Invalid notepat midi UDP payload:", payload); + return true; + } + + let event = payload.event === "note_off" ? "note_off" : "note_on"; + const note = Math.max(0, Math.min(127, Math.round(rawNote))); + const velocity = Math.max(0, Math.min(127, Math.round(rawVelocity))); + const channel = Math.max(0, Math.min(15, Math.round(rawChannel))); + if (event === "note_on" && velocity === 0) event = "note_off"; + + broadcastNotepatMidiEvent({ + type: "notepat:midi", + event, + note, + velocity, + channel, + handle: source.handle, + machineId: source.machineId, + piece: source.piece || "notepat", + ts: Number.isFinite(Number(payload.ts)) ? Number(payload.ts) : now, + }); + + return true; +} + +function pruneNotepatMidiSources() { + const now = Date.now(); + let changed = false; + + for (const [key, source] of notepatMidiSources) { + if (now - (source.lastSeen || 0) > UDP_MIDI_SOURCE_TTL_MS) { + notepatMidiSources.delete(key); + changed = true; + } + } + + if (changed) broadcastNotepatMidiSources(); +} + +udpRelay.on("message", (msg, rinfo) => { + if (msg.length > 0 && msg[0] === 0x01 && msg.length >= 10) { + const key = `${rinfo.address}:${rinfo.port}`; + const x = msg.readFloatLE(1); + const y = msg.readFloatLE(5); + const hlen = msg[9]; + const handle = msg.slice(10, 10 + hlen).toString("utf8"); + + udpClients.set(key, { address: rinfo.address, port: rinfo.port, handle, lastSeen: Date.now() }); + + // Build broadcast packet (type 0x02) + const bcast = Buffer.alloc(msg.length); + msg.copy(bcast); + bcast[0] = 0x02; + + // Broadcast to all other UDP clients + for (const [k, client] of udpClients) { + if (k !== key) { + udpRelay.send(bcast, client.port, client.address); + } + } + + // Also broadcast to Geckos.io WebRTC clients as fairy:point + const fairyData = JSON.stringify({ x, y, handle }); + try { + // Emit to all geckos channels + io.room().emit("fairy:point", fairyData); + } catch (e) { /* ignore */ } + + // Publish to Redis for silo firehose (throttled) + const now = Date.now(); + const lastFairy = fairyThrottle.get(key) || 0; + if (now - lastFairy >= FAIRY_THROTTLE_MS) { + fairyThrottle.set(key, now); + pub.publish("fairy:point", fairyData).catch(() => {}); + } + return; + } + + if (msg.length > 0 && msg[0] === 0x7b) { + try { + const payload = JSON.parse(msg.toString("utf8")); + if (handleNotepatMidiUdpPacket(payload, rinfo)) return; + } catch (err) { + log("๐ŸŽน Failed to parse UDP JSON packet:", err?.message || err); + } + } +}); + +// Clean up stale UDP clients every 30s +setInterval(() => { + const now = Date.now(); + for (const [key, client] of udpClients) { + if (now - client.lastSeen > 30000) udpClients.delete(key); + } + pruneNotepatMidiSources(); +}, 30000); + +udpRelay.bind(UDP_FAIRY_PORT, () => { + console.log(`๐Ÿงš Raw UDP fairy relay listening on port ${UDP_FAIRY_PORT}`); +}); + +// Bridge: forward Geckos fairy:point to UDP clients +// (patched into the existing fairy:point handler above via io.room().emit) +// When a Geckos client sends fairy:point, also relay to UDP clients: +const origFairyHandler = true; // marker โ€” actual bridging done in channel.on("fairy:point") below + +// #endregion UDP fairy relay + +// ๐Ÿšง File Watching in Local Development Mode +// File watching uses: https://github.com/paulmillr/chokidar +if (dev) { + // 1. Watch for local file changes in pieces. + chokidar + .watch("../system/public/aesthetic.computer/disks") + .on("all", (event, path) => { + if (event === "change") { + const piece = path + .split("/") + .pop() + .replace(/\.mjs|\.lisp$/, ""); + everyone(pack("reload", { piece: piece || "*" }, "local")); + } + }); // 2. Watch base system files. + chokidar + .watch([ + "../system/netlify/functions", + "../system/public/privacy-policy.html", + "../system/public/aesthetic-direct.html", + "../system/public/aesthetic.computer/lib", + "../system/public/aesthetic.computer/systems", // This doesn't need a full reload / could just reload the disk module? + "../system/public/aesthetic.computer/boot.mjs", + "../system/public/aesthetic.computer/bios.mjs", + "../system/public/aesthetic.computer/style.css", + "../system/public/kidlisp.com", + "../system/public/l5.aesthetic.computer", + "../system/public/gift.aesthetic.computer", + "../system/public/give.aesthetic.computer", + "../system/public/news.aesthetic.computer", + ]) + .on("all", (event, path) => { + if (event === "change") + everyone(pack("reload", { piece: "*refresh*" }, "local")); + }); + + // 2b. Watch prompt files separately (piece reload instead of full refresh) + chokidar + .watch("../system/public/aesthetic.computer/prompts") + .on("all", (event, path) => { + if (event === "change") { + const filename = path.split("/").pop(); + console.log(`๐ŸŽจ Prompt file changed: ${filename}`); + everyone(pack("reload", { piece: "*piece-reload*" }, "local")); + } + }); + + // 3. Watch vscode extension + chokidar.watch("../vscode-extension/out").on("all", (event, path) => { + if (event === "change") + everyone(pack("vscode-extension:reload", { reload: true }, "local")); + }); +} + +/* +if (termkit) { + term = termkit.terminal; + + const doc = term.createDocument({ + palette: new termkit.Palette(), + }); + + // Create left (log) and right (client list) columns + const leftColumn = new termkit.Container({ + parent: doc, + x: 0, + width: "70%", + height: "100%", + }); + + const rightColumn = new termkit.Container({ + parent: doc, + x: "70%", + width: "30%", + height: "100%", + }); + + term.grabInput(); + + console.log("grabbed input"); + + term.on("key", function (name, matches, data) { + console.log("'key' event:", name); + + // Detect CTRL-C and exit 'manually' + if (name === "CTRL_C") { + process.exit(); + } + }); + + term.on("mouse", function (name, data) { + console.log("'mouse' event:", name, data); + }); + + // Log box in the left column + const logBox = new termkit.TextBox({ + parent: leftColumn, + content: "Your logs will appear here...\n", + scrollable: true, + vScrollBar: true, + x: 0, + y: 0, + width: "100%", + height: "100%", + mouse: true, // to allow mouse interactions if needed + }); + + // Static list box in the right column + const clientList = new termkit.TextBox({ + parent: rightColumn, + content: "Client List:\n", + x: 0, + y: 0, + width: "100%", + height: "100%", + }); + + // Example functions to update contents + function addLog(message) { + logBox.setContent(logBox.getContent() + message + "\n"); + // logBox.scrollBottom(); + doc.draw(); + } + + function updateClientList(clients) { + clientList.setContent("Client List:\n" + clients.join("\n")); + doc.draw(); + } + + // Example usage + addLog("Server started..."); + updateClientList(["Client1", "Client2"]); + + // Handle input for graceful exit + // term.grabInput(); + // term.on("key", (key) => { + // if (key === "CTRL_C") { + // process.exit(); + // } + // }); + + // doc.draw(); +} +*/ + +function log() { + console.log(...arguments); +} + +function error() { + console.error(...arguments); +} diff --git a/session-server/arena-manager.mjs b/session-server/world-manager.mjs similarity index 100% rename from session-server/arena-manager.mjs rename to session-server/world-manager.mjs