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