From 130b0371c5c795fd7d03938a1b9f0b2ed54767da Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Wed, 8 Oct 2025 15:08:02 -0700 Subject: [PATCH] Restructuring backend to get media to work. --- client/src/GlobalContext.ts | 1 + client/src/MediaControl.tsx | 48 +-- client/src/PlayerColor.tsx | 20 +- client/src/PlayerList.tsx | 93 ++++- client/src/RoomView.tsx | 17 +- .../plugins/vite-console-forward-plugin.js | 131 ++++++++ client/src/setupProxy.js | 16 +- client/vite.config.js | 53 +-- server/console-line.ts | 95 ++++-- server/routes/games.ts | 285 ++-------------- server/routes/webrtc-signaling.ts | 317 ++++++++++++++++++ 11 files changed, 718 insertions(+), 358 deletions(-) create mode 100644 client/src/plugins/vite-console-forward-plugin.js create mode 100644 server/routes/webrtc-signaling.ts diff --git a/client/src/GlobalContext.ts b/client/src/GlobalContext.ts index cc96dc3..2b1e460 100644 --- a/client/src/GlobalContext.ts +++ b/client/src/GlobalContext.ts @@ -6,6 +6,7 @@ export type GlobalContextType = { sendJsonMessage?: (message: any) => void; chat?: Array; socketUrl?: string; + readyState?: any; session?: Session; lastJsonMessage?: any; }; diff --git a/client/src/MediaControl.tsx b/client/src/MediaControl.tsx index 957603d..53e2cf3 100644 --- a/client/src/MediaControl.tsx +++ b/client/src/MediaControl.tsx @@ -8,8 +8,9 @@ import VideocamOff from "@mui/icons-material/VideocamOff"; import Videocam from "@mui/icons-material/Videocam"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; -import useWebSocket, { ReadyState } from "react-use-websocket"; -import { Session } from "./GlobalContext"; +import { ReadyState } from "react-use-websocket"; +import { Session, GlobalContext } from "./GlobalContext"; +import { useContext } from "react"; import WebRTCStatus from "./WebRTCStatus"; import Moveable from "react-moveable"; import { flushSync } from "react-dom"; @@ -308,7 +309,6 @@ const Video: React.FC = ({ srcObject, local, ...props }) => { /* ---------- MediaAgent (signaling + peer setup) ---------- */ type MediaAgentProps = { - socketUrl: string; session: Session; peers: Record; setPeers: React.Dispatch>>; @@ -317,7 +317,7 @@ type MediaAgentProps = { type JoinStatus = { status: "Not joined" | "Joining" | "Joined" | "Error"; message?: string }; const MediaAgent = (props: MediaAgentProps) => { - const { peers, setPeers, socketUrl, session } = props; + const { peers, setPeers, session } = props; const [joinStatus, setJoinStatus] = useState({ status: "Not joined" }); const [media, setMedia] = useState(null); const [pendingPeers, setPendingPeers] = useState([]); @@ -354,37 +354,8 @@ const MediaAgent = (props: MediaAgentProps) => { [setPeers] ); - const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl, { - share: true, - shouldReconnect: (closeEvent) => true, // Auto-reconnect on connection loss - reconnectInterval: 5000, - onError: (err) => { - console.error(err); - }, - onClose: (_event: CloseEvent) => { - if (!session) return; - - console.log(`media-agent - ${session.name} Disconnected from signaling server`); - - // Clean up all peer connections - connectionsRef.current.forEach((connection, peerId) => { - connection.close(); - }); - connectionsRef.current.clear(); - - // Mark all peers as dead - const updatedPeers = { ...peers }; - Object.keys(updatedPeers).forEach((id) => { - if (!updatedPeers[id].local) { - updatedPeers[id].dead = true; - updatedPeers[id].connection = undefined; - } - }); - - if (debug) console.log(`media-agent - close`, updatedPeers); - setPeers(updatedPeers); - }, - }); + // Use the global websocket provided by RoomView to avoid duplicate sockets + const { sendJsonMessage, lastJsonMessage, readyState } = useContext(GlobalContext); useEffect(() => { for (let peer in peers) { @@ -1120,14 +1091,17 @@ const MediaAgent = (props: MediaAgentProps) => { // Join lobby when media is ready useEffect(() => { - if (media && joinStatus.status === "Not joined" && readyState === ReadyState.OPEN) { + // Only attempt to join once we have local media, an open socket, and a known session name. + // Joining with a null/empty name can cause the signaling server to treat the peer as anonymous + // which results in other peers not receiving expected addPeer/track messages. + if (media && joinStatus.status === "Not joined" && readyState === ReadyState.OPEN && session && session.name) { console.log(`media-agent - Initiating media join for ${session.name}`); setJoinStatus({ status: "Joining" }); sendJsonMessage({ type: "join", data: { has_media: session.has_media !== false, // Default to true for backward compatibility - } + }, }); } }, [media, joinStatus.status, sendJsonMessage, readyState, session.name, session.has_media]); diff --git a/client/src/PlayerColor.tsx b/client/src/PlayerColor.tsx index 108c9ae..814b2a6 100644 --- a/client/src/PlayerColor.tsx +++ b/client/src/PlayerColor.tsx @@ -5,8 +5,26 @@ import { styles } from "./Styles"; type PlayerColorProps = { color?: string }; +const mapColor = (c?: string) => { + if (!c) return undefined; + const key = c.toLowerCase(); + switch (key) { + case "red": + return "R"; + case "orange": + return "O"; + case "white": + return "W"; + case "blue": + return "B"; + default: + return undefined; + } +}; + const PlayerColor: React.FC = ({ color }) => { - return ; + const k = mapColor(color) as keyof typeof styles | undefined; + return ; }; export { PlayerColor }; diff --git a/client/src/PlayerList.tsx b/client/src/PlayerList.tsx index d9b5255..45bd525 100644 --- a/client/src/PlayerList.tsx +++ b/client/src/PlayerList.tsx @@ -3,6 +3,7 @@ import Paper from "@mui/material/Paper"; import List from "@mui/material/List"; import "./PlayerList.css"; import { MediaControl, MediaAgent, Peer } from "./MediaControl"; +import { PlayerColor } from "./PlayerColor"; import Box from "@mui/material/Box"; import { GlobalContext } from "./GlobalContext"; @@ -13,6 +14,7 @@ type Player = { local: boolean /* Client side variable */; protected?: boolean; has_media?: boolean; // Whether this Player provides audio/video streams + color?: string; bot_run_id?: string; bot_provider_id?: string; bot_instance_id?: string; // For bot instances @@ -24,6 +26,27 @@ const PlayerList: React.FC = () => { const { session, socketUrl, lastJsonMessage, sendJsonMessage } = useContext(GlobalContext); const [players, setPlayers] = useState(null); const [peers, setPeers] = useState>({}); + useEffect(() => { + console.log("PlayerList - Mounted - requesting fields"); + if (sendJsonMessage) { + sendJsonMessage({ + type: "get", + fields: ["participants"], + }); + } + }, [sendJsonMessage]); + + // Debug logging + useEffect(() => { + console.log("PlayerList - Debug state:", { + session_id: session?.id, + session_name: session?.name, + players_count: players?.length, + players: players, + peers_keys: Object.keys(peers), + peers: peers, + }); + }, [players, peers, session]); const sortPlayers = useCallback( (A: any, B: any) => { @@ -124,6 +147,18 @@ const PlayerList: React.FC = () => { }); }, [players, sendJsonMessage]); + // Debug logging + useEffect(() => { + console.log("PlayerList - Debug state:", { + session_id: session?.id, + session_name: session?.name, + players_count: players?.length, + players: players, + peers_keys: Object.keys(peers), + peers: peers, + }); + }, [players, peers, session]); + return ( { m: { xs: 0, sm: 2 }, }} > - + {players?.map((player) => ( { {player.name && !player.live &&
}
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? ( - + <> + + + {/* If this is the local player and they haven't picked a color, show a picker */} + {player.local && !player.color && ( +
+
Pick your color:
+
+ {[ + { label: "Orange", value: "orange" }, + { label: "Red", value: "red" }, + { label: "White", value: "white" }, + { label: "Blue", value: "blue" }, + ].map((c) => ( + + ))} +
+
+ )} + ) : player.name && player.live && player.has_media === false ? (
{ const priv = data.update.private; if (priv.name !== name) { setName(priv.name); + // Mirror the name into the shared session so consumers that read + // `session.name` (eg. MediaAgent) will see the name and can act + // (for example, initiate the media join). + try { + setSession((s) => (s ? { ...s, name: priv.name } : s)); + } catch (e) { + console.warn("Failed to set session name from private payload", e); + } } if (priv.color !== color) { setColor(priv.color); @@ -178,6 +186,13 @@ const RoomView = (props: RoomProps) => { if ("name" in data.update) { if (data.update.name) { setName(data.update.name); + // Also update the session object so components using session.name + // immediately observe the change. + try { + setSession((s) => (s ? { ...s, name: data.update.name } : s)); + } catch (e) { + console.warn("Failed to set session name from name payload", e); + } } else { setWarning(""); setError(""); @@ -284,7 +299,7 @@ const RoomView = (props: RoomProps) => { } return ( - +
{!name ? ( diff --git a/client/src/plugins/vite-console-forward-plugin.js b/client/src/plugins/vite-console-forward-plugin.js new file mode 100644 index 0000000..aa8ee78 --- /dev/null +++ b/client/src/plugins/vite-console-forward-plugin.js @@ -0,0 +1,131 @@ +// Lightweight Vite plugin that injects a small client-side script to +// forward selected console messages to the dev server. The dev server +// registers an endpoint at /__console_forward and will log the messages +// so you can see browser console output from the container logs. + +export function consoleForwardPlugin(opts = {}) { + const levels = Array.isArray(opts.levels) && opts.levels.length ? opts.levels : ["log", "warn", "error"]; + const enabled = opts.enabled !== false; // Default to true + + if (!enabled) { + // No-op plugin + return { + name: "vite-console-forward-plugin", + enforce: "pre", + transformIndexHtml(html) { + return html; + }, + }; + } + return { + name: "vite-console-forward-plugin", + enforce: "pre", + + configureServer(server) { + // Register a simple POST handler to receive the forwarded console + // messages from the browser. We keep it minimal and robust. + server.middlewares.use("/__console_forward", (req, res, next) => { + if (req.method !== "POST") return next(); + + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const payload = JSON.parse(body || "{}"); + const lvl = payload.level || "log"; + const args = Array.isArray(payload.args) ? payload.args : payload.args ? [payload.args] : []; + const stack = payload.stack; + + // Print an informative prefix so these lines are easy to grep in container logs + if (stack) { + console.error("[frontend][error]", stack); + } + + // Use the requested console level where available; fall back to console.log + const fn = console[lvl] || console.log; + try { + fn("[frontend]", ...args); + } catch (e) { + // Ensure we don't crash the dev server due to malformed payloads + console.log("[frontend][fallback]", ...args); + } + } catch (e) { + console.error("console-forward: failed to parse payload", e); + } + res.statusCode = 204; + res.end(); + }); + }); + }, + + transformIndexHtml(html) { + // Only inject the forwarding script in non-production mode. Vite sets + // NODE_ENV during the dev server; keep this conservative. + if (process.env.NODE_ENV === "production") return html; + + const script = `(function(){ + if (window.__vite_console_forward_installed__) return; + window.__vite_console_forward_installed__ = true; + + function safeSerialize(v){ + try { return typeof v === 'string' ? v : JSON.stringify(v); } + catch(e){ try{ return String(v); }catch(_){ return 'unserializable'; } } + } + +var levels = ${JSON.stringify(levels)}; +levels.forEach(function(l){ + var orig = console[l]; + if (!orig) return; + + console[l] = function(){ + // Call original first + try{ orig.apply(console, arguments); }catch(e){/* ignore */} + + // Capture the real caller from the stack + try{ + var stack = new Error().stack; + var callerLine = null; + + // Parse stack to find the first line that's NOT this wrapper + if (stack) { + var lines = stack.split('\\n'); + // Skip the first 2-3 lines (Error, this wrapper) + for (var i = 2; i < lines.length; i++) { + if (lines[i] && !lines[i].includes('vite-console-forward')) { + callerLine = lines[i].trim(); + break; + } + } + } + + var args = Array.prototype.slice.call(arguments).map(safeSerialize); + var payload = JSON.stringify({ + level: l, + args: args, + caller: callerLine // Include the real caller + }); + + if (navigator.sendBeacon) { + try { navigator.sendBeacon('/__console_forward', payload); return; } catch(e){} + } + fetch('/__console_forward', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }).catch(function(){/* ignore */}); + }catch(e){/* ignore */} + }; +}); + + window.addEventListener('error', function(ev){ + try{ + var stack = ev && ev.error && ev.error.stack ? ev.error.stack : (ev.message + ' at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno); + var payload = JSON.stringify({ level: 'error', stack: stack }); + if (navigator.sendBeacon) { try { navigator.sendBeacon('/__console_forward', payload); return; } catch(e){} } + fetch('/__console_forward', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }).catch(function(){}); + }catch(e){} + }, true); +})();`; + + return html.replace(/<\/head>/i, ``); + }, + }; +} + +export default consoleForwardPlugin; diff --git a/client/src/setupProxy.js b/client/src/setupProxy.js index 21f1408..85b3a40 100644 --- a/client/src/setupProxy.js +++ b/client/src/setupProxy.js @@ -1,20 +1,34 @@ /* eslint-disable @typescript-eslint/no-var-requires, no-undef, @typescript-eslint/no-explicit-any */ const { createProxyMiddleware } = require('http-proxy-middleware'); +const http = require('http'); module.exports = function(app) { - const base = process.env.PUBLIC_URL; + const base = process.env.PUBLIC_URL || ''; console.log(`http-proxy-middleware ${base}`); + + // Keep-alive agent for websocket target to reduce connection churn + const keepAliveAgent = new http.Agent({ keepAlive: true }); + app.use(createProxyMiddleware( `${base}/api/v1/games/ws`, { ws: true, target: 'ws://pok-server:8930', changeOrigin: true, + // use a persistent agent so the proxy reuses sockets for upstream + agent: keepAliveAgent, + // disable proxy timeouts in dev so intermediate proxies don't drop idle WS + proxyTimeout: 0, + timeout: 0, pathRewrite: { [`^${base}`]: '' }, })); + app.use(createProxyMiddleware( `${base}/api`, { target: 'http://pok-server:8930', changeOrigin: true, + // give HTTP API calls a longer timeout in dev + proxyTimeout: 120000, + timeout: 120000, pathRewrite: { [`^${base}`]: '' }, })); }; diff --git a/client/vite.config.js b/client/vite.config.js index 5352141..5307042 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -5,6 +5,10 @@ import fs from 'fs'; const httpsEnv = (process.env.HTTPS || '').toLowerCase(); const useHttps = httpsEnv === 'true' || httpsEnv === '1'; import tsconfigPaths from 'vite-tsconfig-paths' +// Forward browser console messages to the dev server logs so container logs +// show frontend console output. This is enabled only when running the dev +// server (not for production builds). +import { consoleForwardPlugin } from './src/plugins/vite-console-forward-plugin.js' // If custom cert paths are provided via env, use them; otherwise let Vite handle a self-signed cert when true. @@ -40,14 +44,19 @@ export default defineConfig({ plugins: [ react(), tsconfigPaths(), + // Only enable the console forwarding plugin while running the dev + // server (NODE_ENV typically not 'production'). It is safe to include + // here because the plugin's transformIndexHtml is a no-op in + // production by checking NODE_ENV. + consoleForwardPlugin({ enabled: false, levels: ["log", "warn", "error"] }), // Dev-only plugin: when the dev server receives requests that are // already prefixed with the base (e.g. /ketr.ketran/assets/...), strip // the prefix so Vite can serve the underlying files from /assets/... { - name: 'strip-basepath-for-dev', + name: "strip-basepath-for-dev", configureServer(server) { // Only install the middleware when a non-root base is configured - if (!normalizedBase || normalizedBase === '/') return; + if (!normalizedBase || normalizedBase === "/") return; server.middlewares.use((req, res, next) => { try { // Log incoming base-prefixed requests for debugging only. Do NOT @@ -67,15 +76,15 @@ export default defineConfig({ // asset paths here to avoid interfering with module paths // and HMR endpoints which Vite already serves correctly // when the server `base` is configured. - const assetsPrefix = normalizedBase.replace(/\/$/, '') + '/assets/'; + const assetsPrefix = normalizedBase.replace(/\/$/, "") + "/assets/"; if (req.url.indexOf(assetsPrefix) === 0) { const original = req.url; // Preserve the base and change '/assets/' to '/gfx/' so the // dev server serves files from public/gfx which are exposed at // '//gfx/...'. Example: '/ketr.ketran/assets/gfx/x' -> // '/ketr.ketran/gfx/x'. - const baseNoTrail = normalizedBase.replace(/\/$/, ''); - req.url = req.url.replace(new RegExp('^' + baseNoTrail + '/assets/'), baseNoTrail + '/gfx/'); + const baseNoTrail = normalizedBase.replace(/\/$/, ""); + req.url = req.url.replace(new RegExp("^" + baseNoTrail + "/assets/"), baseNoTrail + "/gfx/"); console.log(`[vite] rewritten asset ${original} -> ${req.url}`); } } @@ -85,41 +94,41 @@ export default defineConfig({ } next(); }); - } - } + }, + }, ], build: { - outDir: 'build', + outDir: "build", }, server: { - host: process.env.HOST || '0.0.0.0', + host: process.env.HOST || "0.0.0.0", port: Number(process.env.PORT) || 3001, https: httpsOption, proxy: { // Support requests that already include the basePath (/ketr.ketran/api) // and requests that use the shorter /api path. Both should be forwarded // to the backend server which serves the API under /ketr.ketran/api. - '/ketr.ketran/api': { - target: 'http://pok-server:8930', - changeOrigin: true, - ws: true, - secure: false - }, - '/api': { - target: 'http://pok-server:8930', + "/ketr.ketran/api": { + target: "http://pok-server:8930", changeOrigin: true, ws: true, secure: false, - rewrite: (path) => `/ketr.ketran${path}` - } + }, + "/api": { + target: "http://pok-server:8930", + changeOrigin: true, + ws: true, + secure: false, + rewrite: (path) => `/ketr.ketran${path}`, + }, }, // HMR options: advertise the external hostname and port so browsers // accessing via `battle-linux.ketrenos.com` can connect to the websocket. // The certs mounted into the container must be trusted by the browser. hmr: { - host: process.env.VITE_HMR_HOST || 'battle-linux.ketrenos.com', + host: process.env.VITE_HMR_HOST || "battle-linux.ketrenos.com", port: Number(process.env.VITE_HMR_PORT) || 3001, - protocol: process.env.VITE_HMR_PROTOCOL || 'wss' + protocol: process.env.VITE_HMR_PROTOCOL || "wss", }, - } + }, }); diff --git a/server/console-line.ts b/server/console-line.ts index 4edc689..4613500 100755 --- a/server/console-line.ts +++ b/server/console-line.ts @@ -1,28 +1,73 @@ -/* monkey-patch console.log to prefix with file/line-number */ -if (process.env['LOG_LINE']) { - let cwd = process.cwd(), - cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "\/([^:]*:[0-9]*).*$"); - [ "log", "warn", "error" ].forEach(function(method: string) { - (console as any)[method] = (function () { - let orig = (console as any)[method]; - return function (this: any, ...args: any[]) { - function getErrorObject(): Error { - try { - throw Error(''); - } catch (err) { - return err as Error; - } +/* monkey-patch console methods to prefix messages with file:line for easier logs */ +(() => { + const cwd = process.cwd(); + const cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "/([^:]*:[0-9]*).*$"); + const methods = ["log", "warn", "error", "info", "debug"] as const; + + function getCallerFileLine(): string { + try { + // Create an Error to capture stack + const err = new Error(); + if (!err.stack) return "unknown:0 -"; + const lines = err.stack.split("\n").slice(1); + // Find the first stack line that is not this file + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + if (line.indexOf("console-line") !== -1) continue; // skip this helper + // Try to extract file:line from the line. Use a stricter capture so we + // don't accidentally include leading whitespace or the 'at' token. + const m = line.match(/\(?(\S+:\d+:\d+)\)?$/); + if (m && m[1]) { + return m[1].trim() + " -"; + } + } + // Fallback: try to extract file:line:col from the third stack line even + // if it contains leading whitespace and the 'at' prefix. If that fails, + // fall back to the cwd-based replace and trim whitespace. + const fallback = err.stack.split("\n")[3] || ""; + const m2 = fallback.match(/\(?(\S+:\d+:\d+)\)?$/); + if (m2 && m2[1]) return m2[1].trim() + " -"; + const replaced = fallback.replace(cwdRe, "$1 -").trim(); + return replaced || "unknown:0 -"; + } catch (e) { + return "unknown:0 -"; + } + } + + methods.forEach((method) => { + const orig = (console as any)[method] || console.log; + (console as any)[method] = function (...args: any[]) { + try { + const prefix = getCallerFileLine(); + + // Separate Error objects from other args so we can print their stacks + // line-by-line with the same prefix. This keeps stack traces intact + // while ensuring every printed line shows the caller prefix. + const errorArgs = args.filter((a: any) => a instanceof Error) as Error[]; + const otherArgs = args.filter((a: any) => !(a instanceof Error)); + + // Print non-error args in a single call (preserving original formatting) + const processedOther = otherArgs.map((a: any) => (a instanceof Error ? a.stack || a.toString() : a)); + if (processedOther.length > 0) { + orig.apply(this, [prefix, ...processedOther]); } - let err = getErrorObject(), - caller_line = err.stack?.split("\n")[3] || '', - prefixedArgs = [caller_line.replace(cwdRe, "$1 -")]; - - /* arguments.unshift() doesn't exist... */ - prefixedArgs.push(...args); - - orig.apply(this, prefixedArgs); - }; - })(); + // For each Error, print each line of its stack as a separate prefixed log + // entry so lines that begin with ' at' are not orphaned. + errorArgs.forEach((err) => { + const stack = err.stack || err.toString(); + stack.split("\n").forEach((line) => { + orig.apply(this, [prefix, line]); + }); + }); + } catch (e) { + try { + orig.apply(this, args); + } catch (e2) { + /* swallow */ + } + } + }; }); -} +})(); diff --git a/server/routes/games.ts b/server/routes/games.ts index ed90232..ad0de53 100755 --- a/server/routes/games.ts +++ b/server/routes/games.ts @@ -19,6 +19,14 @@ import { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLoca import { Player, Game, Session, CornerPlacement, RoadPlacement, Offer, Turn } from "./games/types"; import { newPlayer } from "./games/playerFactory"; import { normalizeIncoming, shuffleArray } from "./games/utils"; +import { + audio as audioMap, + join as webrtcJoin, + part as webrtcPart, + handleRelayICECandidate, + handleRelaySessionDescription, + broadcastPeerStateUpdate, +} from "./webrtc-signaling"; // import type { GameState } from './games/state'; // unused import removed during typing pass const router = express.Router(); @@ -51,7 +59,9 @@ initGameDB() // shuffleArray imported from './games/utils.ts' const games: Record = {}; -const audio: Record = {}; + +// Re-exported audio map from webrtc-signaling for in-file use +const audio = audioMap; const processTies = (players: Player[]): boolean => { /* Sort the players into buckets based on their @@ -1116,12 +1126,12 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und } session.name = name; if (session.ws && game.id in audio && session.name in audio[game.id]) { - part(audio[game.id], session); + webrtcPart(audio[game.id], session); } } else { message = `${session.name} has changed their name to ${name}.`; if (session.ws && game.id in audio) { - part(audio[game.id], session); + webrtcPart(audio[game.id], session); } } } @@ -1138,7 +1148,7 @@ const setPlayerName = (game: Game, session: Session, name: string): string | und } if (session.ws && session.hasAudio) { - join(audio[game.id], session, { + webrtcJoin(audio[game.id], session, { hasVideo: session.video ? true : false, hasAudio: session.audio ? true : false, }); @@ -3412,171 +3422,8 @@ const resetDisconnectCheck = (_game: any, req: any): void => { //req.disconnectCheck = setTimeout(() => { wsInactive(game, req) }, 20000); }; -const join = (peers: any, session: any, { hasVideo, hasAudio, has_media }: { hasVideo?: boolean; hasAudio?: boolean; has_media?: boolean }): void => { - const ws = session.ws; - - if (!session.name) { - console.error(`${session.id}: <- join - No name set yet. Audio not available.`); - ws.send(JSON.stringify({ - type: "join_status", - status: "Error", - message: "No name set yet. Audio not available." - })); - return; - } - - console.log(`${session.id}: <- join - ${session.name}`); - console.log(`${all}: -> addPeer - ${session.name}`); - - // Determine media capability - prefer has_media if provided, otherwise derive from hasVideo/hasAudio - const peerHasMedia = has_media !== undefined ? has_media : (hasVideo || hasAudio); - - if (session.name in peers) { - console.log(`${session.id}:${session.name} - Already joined to Audio, updating WebSocket reference.`); - - // Update the WebSocket reference in case of reconnection - peers[session.name].ws = ws; - peers[session.name].has_media = peerHasMedia; - peers[session.name].hasAudio = hasAudio; - peers[session.name].hasVideo = hasVideo; - - // Send join status to reconnected client - ws.send(JSON.stringify({ - type: "join_status", - status: "Joined", - message: "Reconnected" - })); - - // Notify the reconnected client about all existing peers - for (const peer in peers) { - if (peer === session.name) continue; // Skip self - - ws.send( - JSON.stringify({ - type: "addPeer", - data: { - peer_id: peer, - peer_name: peer, - has_media: peers[peer].has_media, - should_create_offer: true, - hasAudio: peers[peer].hasAudio, - hasVideo: peers[peer].hasVideo, - }, - }) - ); - } - - // Notify all other peers about the reconnected peer (with updated connection) - for (const peer in peers) { - if (peer === session.name) continue; // Skip self - - peers[peer].ws.send( - JSON.stringify({ - type: "addPeer", - data: { - peer_id: session.name, - peer_name: session.name, - has_media: peerHasMedia, - should_create_offer: false, - hasAudio, - hasVideo, - }, - }) - ); - } - - return; - } - - for (let peer in peers) { - /* Add this caller to all peers */ - peers[peer].ws.send( - JSON.stringify({ - type: "addPeer", - data: { - peer_id: session.name, - peer_name: session.name, - has_media: peers[session.name]?.has_media ?? peerHasMedia, - should_create_offer: false, - hasAudio, - hasVideo, - }, - }) - ); - - /* Add each other peer to the caller */ - ws.send( - JSON.stringify({ - type: "addPeer", - data: { - peer_id: peer, - peer_name: peer, - has_media: peers[peer].has_media, - should_create_offer: true, - hasAudio: peers[peer].hasAudio, - hasVideo: peers[peer].hasVideo, - }, - }) - ); - } - - /* Add this user as a peer connected to this WebSocket */ - peers[session.name] = { - ws, - hasAudio, - hasVideo, - has_media: peerHasMedia, - }; - - /* Send join success status */ - ws.send(JSON.stringify({ - type: "join_status", - status: "Joined", - message: "Successfully joined" - })); -}; - -const part = (peers: any, session: any): void => { - const ws = session.ws; - - if (!session.name) { - console.error(`${session.id}: <- part - No name set yet. Audio not available.`); - return; - } - - if (!(session.name in peers)) { - console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`); - return; - } - - console.log(`${session.id}: <- ${session.name} - Audio part.`); - console.log(`${all}: -> removePeer - ${session.name}`); - - delete peers[session.name]; - - /* Remove this peer from all other peers, and remove each - * peer from this peer */ - for (let peer in peers) { - peers[peer].ws.send( - JSON.stringify({ - type: "removePeer", - data: { - peer_id: session.name, - peer_name: session.name - }, - }) - ); - ws.send( - JSON.stringify({ - type: "removePeer", - data: { - peer_id: peer, - peer_name: peer - }, - }) - ); - } -}; +// WebRTC join/part handling moved to server/routes/webrtc-signaling.ts +// use webrtcJoin(audio[gameId], session, config) and webrtcPart(audio[gameId], session) const getName = (session: any): string => { return session ? (session.name ? session.name : session.id) : "Admin"; @@ -4262,7 +4109,7 @@ router.ws("/ws/:id", async (ws, req) => { /* Cleanup any voice channels */ if (gameId in audio) { try { - part(audio[gameId], session); + webrtcPart(audio[gameId], session); } catch (e) { console.warn(`${short}: Error during part():`, e); } @@ -4354,7 +4201,7 @@ router.ws("/ws/:id", async (ws, req) => { // Clean up peer from audio registry before replacing WebSocket if (gameId in audio) { try { - part(audio[gameId], session); + webrtcPart(audio[gameId], session); console.log(`${short}: Cleaned up peer ${session.name} from audio registry during reconnection`); } catch (e) { console.warn(`${short}: Error cleaning up peer during reconnection:`, e); @@ -4396,75 +4243,25 @@ router.ws("/ws/:id", async (ws, req) => { case "join": // Accept either legacy `config` or newer `data` field from clients - join(audio[gameId], session, data.config || data.data || {}); + webrtcJoin(audio[gameId], session, data.config || data.data || {}); break; case "part": - part(audio[gameId], session); + webrtcPart(audio[gameId], session); break; case "relayICECandidate": { - if (!(gameId in audio)) { - console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`); - return; - } - - // Support both { config: {...} } and { data: {...} } client payloads + // Delegate to the webrtc signaling helper (it performs its own checks) const cfg = data.config || data.data || {}; - const { peer_id, candidate } = cfg; - if (debug.audio) - console.log(`${short}:${id} <- relayICECandidate ${getName(session)} to ${peer_id}`, candidate); - - message = JSON.stringify({ - type: "iceCandidate", - data: { - peer_id: getName(session), - peer_name: getName(session), - candidate: candidate - }, - }) as any; - - if (peer_id in audio[gameId]) { - try { - (audio[gameId][peer_id] as any).ws.send(message as any); - } catch (e) { - /* ignore */ - } - } + handleRelayICECandidate(gameId, cfg, session, undefined, debug); } break; case "relaySessionDescription": { - if (!(gameId in audio)) { - console.error(`${gameId} - relaySessionDescription - Does not have Audio`); - return; - } - - // Support both { config: {...} } and { data: {...} } client payloads const cfg = data.config || data.data || {}; - const { peer_id, session_description } = cfg; - if (debug.audio) - console.log( - `${short}:${id} - relaySessionDescription ${getName(session)} to ${peer_id}`, - session_description - ); - message = JSON.stringify({ - type: "sessionDescription", - data: { - peer_id: getName(session), - peer_name: getName(session), - session_description: session_description - }, - }) as any; - if (peer_id in audio[gameId]) { - try { - (audio[gameId][peer_id] as any).ws.send(message as any); - } catch (e) { - /* ignore */ - } - } + handleRelaySessionDescription(gameId, cfg, session, undefined, debug); } break; @@ -4479,42 +4276,8 @@ router.ws("/ws/:id", async (ws, req) => { case "peer_state_update": { - // Broadcast a peer state update (muted/video_on) to other peers in the game audio map - if (!(gameId in audio)) { - console.error(`${session.id}:${gameId} <- peer_state_update - Does not have Audio`); - return; - } - const cfg = data.config || data.data || {}; - const { muted, video_on } = cfg; - if (!session.name) { - console.error(`${session.id}: peer_state_update - unnamed session`); - return; - } - - const messagePayload = JSON.stringify({ - type: "peer_state_update", - data: { - peer_id: session.name, - peer_name: session.name, - muted, - video_on - }, - }); - - // Send to all other peers - for (const other in audio[gameId]) { - if (other === session.name) continue; - try { - try { - (audio[gameId][other] as any).ws.send(messagePayload as any); - } catch (e) { - /* ignore */ - } - } catch (e) { - console.warn(`Failed sending peer_state_update to ${other}:`, e); - } - } + broadcastPeerStateUpdate(gameId, cfg, session, undefined); } break; diff --git a/server/routes/webrtc-signaling.ts b/server/routes/webrtc-signaling.ts new file mode 100644 index 0000000..67101c8 --- /dev/null +++ b/server/routes/webrtc-signaling.ts @@ -0,0 +1,317 @@ +/* WebRTC signaling helpers extracted from games.ts + * Exports: + * - audio: map of gameId -> peers + * - join(peers, session, config, safeSend) + * - part(peers, session, safeSend) + * - handleRelayICECandidate(gameId, cfg, session, safeSend, debug) + * - handleRelaySessionDescription(gameId, cfg, session, safeSend, debug) + * - broadcastPeerStateUpdate(gameId, cfg, session, safeSend) + */ + +export const audio: Record = {}; + +// Default send helper used when caller doesn't provide a safeSend implementation. +const defaultSend = (targetOrSession: any, message: any): boolean => { + try { + const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null; + if (!target) return false; + target.send(typeof message === "string" ? message : JSON.stringify(message)); + return true; + } catch (e) { + return false; + } +}; + +export const join = ( + peers: any, + session: any, + { hasVideo, hasAudio, has_media }: { hasVideo?: boolean; hasAudio?: boolean; has_media?: boolean }, + safeSend?: (targetOrSession: any, message: any) => boolean +): void => { + const send = safeSend ? safeSend : defaultSend; + const ws = session.ws; + + if (!session.name) { + console.error(`${session.id}: <- join - No name set yet. Audio not available.`); + send(ws, { + type: "join_status", + status: "Error", + message: "No name set yet. Audio not available.", + }); + return; + } + + console.log(`${session.id}: <- join - ${session.name}`); + + // Determine media capability - prefer has_media if provided + const peerHasMedia = has_media !== undefined ? has_media : hasVideo || hasAudio; + + if (session.name in peers) { + console.log(`${session.id}:${session.name} - Already joined to Audio, updating WebSocket reference.`); + try { + const prev = peers[session.name] && peers[session.name].ws; + if (prev && prev._pingInterval) { + clearInterval(prev._pingInterval); + } + } catch (e) { + /* ignore */ + } + peers[session.name].ws = ws; + peers[session.name].has_media = peerHasMedia; + peers[session.name].hasAudio = hasAudio; + peers[session.name].hasVideo = hasVideo; + + send(ws, { + type: "join_status", + status: "Joined", + message: "Reconnected", + }); + + for (const peer in peers) { + if (peer === session.name) continue; + + send(ws, { + type: "addPeer", + data: { + peer_id: peer, + peer_name: peer, + has_media: peers[peer].has_media, + should_create_offer: true, + hasAudio: peers[peer].hasAudio, + hasVideo: peers[peer].hasVideo, + }, + }); + } + + for (const peer in peers) { + if (peer === session.name) continue; + + send(peers[peer].ws, { + type: "addPeer", + data: { + peer_id: session.name, + peer_name: session.name, + has_media: peerHasMedia, + should_create_offer: false, + hasAudio, + hasVideo, + }, + }); + } + + return; + } + + for (let peer in peers) { + send(peers[peer].ws, { + type: "addPeer", + data: { + peer_id: session.name, + peer_name: session.name, + has_media: peers[session.name]?.has_media ?? peerHasMedia, + should_create_offer: false, + hasAudio, + hasVideo, + }, + }); + + send(ws, { + type: "addPeer", + data: { + peer_id: peer, + peer_name: peer, + has_media: peers[peer].has_media, + should_create_offer: true, + hasAudio: peers[peer].hasAudio, + hasVideo: peers[peer].hasVideo, + }, + }); + } + + peers[session.name] = { + ws, + hasAudio, + hasVideo, + has_media: peerHasMedia, + }; + + send(ws, { + type: "join_status", + status: "Joined", + message: "Successfully joined", + }); +}; + +export const part = (peers: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean): void => { + const ws = session.ws; + const send = safeSend + ? safeSend + : defaultSend; + + if (!session.name) { + console.error(`${session.id}: <- part - No name set yet. Audio not available.`); + return; + } + + if (!(session.name in peers)) { + console.log(`${session.id}: <- ${session.name} - Does not exist in game audio.`); + return; + } + + console.log(`${session.id}: <- ${session.name} - Audio part.`); + console.log(`-> removePeer - ${session.name}`); + + delete peers[session.name]; + + for (let peer in peers) { + send(peers[peer].ws, { + type: "removePeer", + data: { + peer_id: session.name, + peer_name: session.name, + }, + }); + send(ws, { + type: "removePeer", + data: { + peer_id: peer, + peer_name: peer, + }, + }); + } +}; + +export const handleRelayICECandidate = ( + gameId: string, + cfg: any, + session: any, + safeSend?: (targetOrSession: any, message: any) => boolean, + debug?: any +) => { + const send = safeSend ? safeSend : defaultSend; + + const ws = session && session.ws; + if (!cfg) { + // Reply with an error to the sender to aid debugging (mirror Python behaviour) + send(ws, { type: "error", data: { error: "relayICECandidate missing data" } }); + return; + } + + if (!(gameId in audio)) { + console.error(`${session.id}:${gameId} <- relayICECandidate - Does not have Audio`); + return; + } + const { peer_id, candidate } = cfg; + if (debug && debug.audio) console.log(`${session.id}:${gameId} <- relayICECandidate ${session.name} to ${peer_id}`, candidate); + + const message = JSON.stringify({ + type: "iceCandidate", + data: { + peer_id: session.name, + peer_name: session.name, + candidate, + }, + }); + + if (peer_id in audio[gameId]) { + const target = audio[gameId][peer_id] as any; + if (!target || !target.ws) { + console.warn(`${session.id}:${gameId} relayICECandidate - target ${peer_id} has no ws`); + } else if (!send(target.ws, message)) { + console.warn(`${session.id}:${gameId} relayICECandidate - send failed to ${peer_id}`); + } + } +}; + +export const handleRelaySessionDescription = ( + gameId: string, + cfg: any, + session: any, + safeSend?: (targetOrSession: any, message: any) => boolean, + debug?: any +) => { + const send = safeSend ? safeSend : defaultSend; + + const ws = session && session.ws; + if (!cfg) { + send(ws, { type: "error", data: { error: "relaySessionDescription missing data" } }); + return; + } + + if (!(gameId in audio)) { + console.error(`${gameId} - relaySessionDescription - Does not have Audio`); + return; + } + const { peer_id, session_description } = cfg; + if (!peer_id) { + send(ws, { type: "error", data: { error: "relaySessionDescription missing peer_id" } }); + return; + } + if (debug && debug.audio) console.log(`${session.id}:${gameId} - relaySessionDescription ${session.name} to ${peer_id}`, session_description); + const message = JSON.stringify({ + type: "sessionDescription", + data: { + peer_id: session.name, + peer_name: session.name, + session_description, + }, + }); + if (peer_id in audio[gameId]) { + const target = audio[gameId][peer_id] as any; + if (!target || !target.ws) { + console.warn(`${session.id}:${gameId} relaySessionDescription - target ${peer_id} has no ws`); + } else if (!send(target.ws, message)) { + console.warn(`${session.id}:${gameId} relaySessionDescription - send failed to ${peer_id}`); + } + } +}; + +export const broadcastPeerStateUpdate = (gameId: string, cfg: any, session: any, safeSend?: (targetOrSession: any, message: any) => boolean) => { + const send = safeSend + ? safeSend + : (targetOrSession: any, message: any) => { + try { + const target = targetOrSession && typeof targetOrSession.send === "function" ? targetOrSession : targetOrSession && targetOrSession.ws ? targetOrSession.ws : null; + if (!target) return false; + target.send(typeof message === "string" ? message : JSON.stringify(message)); + return true; + } catch (e) { + return false; + } + }; + + if (!(gameId in audio)) { + console.error(`${session.id}:${gameId} <- peer_state_update - Does not have Audio`); + return; + } + + const { muted, video_on } = cfg; + if (!session.name) { + console.error(`${session.id}: peer_state_update - unnamed session`); + return; + } + + const messagePayload = JSON.stringify({ + type: "peer_state_update", + data: { + peer_id: session.name, + peer_name: session.name, + muted, + video_on, + }, + }); + + for (const other in audio[gameId]) { + if (other === session.name) continue; + try { + const tgt = audio[gameId][other] as any; + if (!tgt || !tgt.ws) { + console.warn(`${session.id}:${gameId} peer_state_update - target ${other} has no ws`); + } else if (!send(tgt.ws, messagePayload)) { + console.warn(`${session.id}:${gameId} peer_state_update - send failed to ${other}`); + } + } catch (e) { + console.warn(`Failed sending peer_state_update to ${other}:`, e); + } + } +};