1
0

Restructuring backend to get media to work.

This commit is contained in:
James Ketr 2025-10-08 15:08:02 -07:00
parent 9d2c5f2516
commit 130b0371c5
11 changed files with 718 additions and 358 deletions

View File

@ -6,6 +6,7 @@ export type GlobalContextType = {
sendJsonMessage?: (message: any) => void;
chat?: Array<unknown>;
socketUrl?: string;
readyState?: any;
session?: Session;
lastJsonMessage?: any;
};

View File

@ -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<VideoProps> = ({ srcObject, local, ...props }) => {
/* ---------- MediaAgent (signaling + peer setup) ---------- */
type MediaAgentProps = {
socketUrl: string;
session: Session;
peers: Record<string, Peer>;
setPeers: React.Dispatch<React.SetStateAction<Record<string, Peer>>>;
@ -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<JoinStatus>({ status: "Not joined" });
const [media, setMedia] = useState<MediaStream | null>(null);
const [pendingPeers, setPendingPeers] = useState<AddPeerConfig[]>([]);
@ -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]);

View File

@ -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<PlayerColorProps> = ({ color }) => {
return <Avatar sx={color ? styles[color] : {}} className="PlayerColor" />;
const k = mapColor(color) as keyof typeof styles | undefined;
return <Avatar sx={k ? styles[k] : {}} className="PlayerColor" />;
};
export { PlayerColor };

View File

@ -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<Player[] | null>(null);
const [peers, setPeers] = useState<Record<string, Peer>>({});
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 (
<Box sx={{ position: "relative", width: "100%" }}>
<Paper
@ -134,7 +169,7 @@ const PlayerList: React.FC = () => {
m: { xs: 0, sm: 2 },
}}
>
<MediaAgent {...{ session, socketUrl, peers, setPeers }} />
<MediaAgent {...{ session, peers, setPeers }} />
<List className="PlayerSelector">
{players?.map((player) => (
<Box
@ -164,6 +199,7 @@ const PlayerList: React.FC = () => {
{player.name && !player.live && <div className="NoNetwork"></div>}
</Box>
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? (
<>
<MediaControl
className="Medium"
key={player.session_id}
@ -173,6 +209,43 @@ const PlayerList: React.FC = () => {
remoteAudioMuted={peers[player.session_id].muted}
remoteVideoOff={peers[player.session_id].video_on === false}
/>
{/* If this is the local player and they haven't picked a color, show a picker */}
{player.local && !player.color && (
<div style={{ marginTop: 8, width: "100%" }}>
<div style={{ marginBottom: 6, fontSize: "0.9em" }}>Pick your color:</div>
<div style={{ display: "flex", gap: 8 }}>
{[
{ label: "Orange", value: "orange" },
{ label: "Red", value: "red" },
{ label: "White", value: "white" },
{ label: "Blue", value: "blue" },
].map((c) => (
<button
key={c.value}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
borderRadius: 6,
border: "1px solid #ccc",
background: "#fff",
cursor: sendJsonMessage ? "pointer" : "not-allowed",
}}
onClick={() => {
if (!sendJsonMessage) return;
sendJsonMessage({ type: "set", field: "color", value: c.value });
}}
>
<PlayerColor color={c.value} />
<div style={{ fontSize: "0.9em" }}>{c.label}</div>
</button>
))}
</div>
</div>
)}
</>
) : player.name && player.live && player.has_media === false ? (
<div
className="Video fade-in"

View File

@ -168,6 +168,14 @@ const RoomView = (props: RoomProps) => {
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 (
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage }}>
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage, readyState }}>
<div className="RoomView">
{!name ? (
<Paper>

View File

@ -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, `<script>${script}</script></head>`);
},
};
}
export default consoleForwardPlugin;

View File

@ -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}`]: '' },
}));
};

View File

@ -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
// '/<base>/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",
},
},
}
});

View File

@ -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 {
/* 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 {
throw Error('');
} catch (err) {
return err as Error;
// 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 -";
}
}
let err = getErrorObject(),
caller_line = err.stack?.split("\n")[3] || '',
prefixedArgs = [caller_line.replace(cwdRe, "$1 -")];
methods.forEach((method) => {
const orig = (console as any)[method] || console.log;
(console as any)[method] = function (...args: any[]) {
try {
const prefix = getCallerFileLine();
/* arguments.unshift() doesn't exist... */
prefixedArgs.push(...args);
// 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));
orig.apply(this, prefixedArgs);
};
})();
// 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]);
}
// 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 */
}
}
};
});
})();

View File

@ -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<string, Game> = {};
const audio: Record<string, any> = {};
// 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;

View File

@ -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<string, any> = {};
// 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);
}
}
};