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