Restructuring backend to get media to work.
This commit is contained in:
parent
9d2c5f2516
commit
130b0371c5
@ -6,6 +6,7 @@ export type GlobalContextType = {
|
|||||||
sendJsonMessage?: (message: any) => void;
|
sendJsonMessage?: (message: any) => void;
|
||||||
chat?: Array<unknown>;
|
chat?: Array<unknown>;
|
||||||
socketUrl?: string;
|
socketUrl?: string;
|
||||||
|
readyState?: any;
|
||||||
session?: Session;
|
session?: Session;
|
||||||
lastJsonMessage?: any;
|
lastJsonMessage?: any;
|
||||||
};
|
};
|
||||||
|
@ -8,8 +8,9 @@ import VideocamOff from "@mui/icons-material/VideocamOff";
|
|||||||
import Videocam from "@mui/icons-material/Videocam";
|
import Videocam from "@mui/icons-material/Videocam";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
import { ReadyState } from "react-use-websocket";
|
||||||
import { Session } from "./GlobalContext";
|
import { Session, GlobalContext } from "./GlobalContext";
|
||||||
|
import { useContext } from "react";
|
||||||
import WebRTCStatus from "./WebRTCStatus";
|
import WebRTCStatus from "./WebRTCStatus";
|
||||||
import Moveable from "react-moveable";
|
import Moveable from "react-moveable";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
@ -308,7 +309,6 @@ const Video: React.FC<VideoProps> = ({ srcObject, local, ...props }) => {
|
|||||||
/* ---------- MediaAgent (signaling + peer setup) ---------- */
|
/* ---------- MediaAgent (signaling + peer setup) ---------- */
|
||||||
|
|
||||||
type MediaAgentProps = {
|
type MediaAgentProps = {
|
||||||
socketUrl: string;
|
|
||||||
session: Session;
|
session: Session;
|
||||||
peers: Record<string, Peer>;
|
peers: Record<string, Peer>;
|
||||||
setPeers: React.Dispatch<React.SetStateAction<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 };
|
type JoinStatus = { status: "Not joined" | "Joining" | "Joined" | "Error"; message?: string };
|
||||||
|
|
||||||
const MediaAgent = (props: MediaAgentProps) => {
|
const MediaAgent = (props: MediaAgentProps) => {
|
||||||
const { peers, setPeers, socketUrl, session } = props;
|
const { peers, setPeers, session } = props;
|
||||||
const [joinStatus, setJoinStatus] = useState<JoinStatus>({ status: "Not joined" });
|
const [joinStatus, setJoinStatus] = useState<JoinStatus>({ status: "Not joined" });
|
||||||
const [media, setMedia] = useState<MediaStream | null>(null);
|
const [media, setMedia] = useState<MediaStream | null>(null);
|
||||||
const [pendingPeers, setPendingPeers] = useState<AddPeerConfig[]>([]);
|
const [pendingPeers, setPendingPeers] = useState<AddPeerConfig[]>([]);
|
||||||
@ -354,37 +354,8 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
[setPeers]
|
[setPeers]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl, {
|
// Use the global websocket provided by RoomView to avoid duplicate sockets
|
||||||
share: true,
|
const { sendJsonMessage, lastJsonMessage, readyState } = useContext(GlobalContext);
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (let peer in peers) {
|
for (let peer in peers) {
|
||||||
@ -1120,14 +1091,17 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
|
|
||||||
// Join lobby when media is ready
|
// Join lobby when media is ready
|
||||||
useEffect(() => {
|
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}`);
|
console.log(`media-agent - Initiating media join for ${session.name}`);
|
||||||
setJoinStatus({ status: "Joining" });
|
setJoinStatus({ status: "Joining" });
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "join",
|
type: "join",
|
||||||
data: {
|
data: {
|
||||||
has_media: session.has_media !== false, // Default to true for backward compatibility
|
has_media: session.has_media !== false, // Default to true for backward compatibility
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [media, joinStatus.status, sendJsonMessage, readyState, session.name, session.has_media]);
|
}, [media, joinStatus.status, sendJsonMessage, readyState, session.name, session.has_media]);
|
||||||
|
@ -5,8 +5,26 @@ import { styles } from "./Styles";
|
|||||||
|
|
||||||
type PlayerColorProps = { color?: string };
|
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 }) => {
|
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 };
|
export { PlayerColor };
|
||||||
|
@ -3,6 +3,7 @@ import Paper from "@mui/material/Paper";
|
|||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import "./PlayerList.css";
|
import "./PlayerList.css";
|
||||||
import { MediaControl, MediaAgent, Peer } from "./MediaControl";
|
import { MediaControl, MediaAgent, Peer } from "./MediaControl";
|
||||||
|
import { PlayerColor } from "./PlayerColor";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { GlobalContext } from "./GlobalContext";
|
import { GlobalContext } from "./GlobalContext";
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ type Player = {
|
|||||||
local: boolean /* Client side variable */;
|
local: boolean /* Client side variable */;
|
||||||
protected?: boolean;
|
protected?: boolean;
|
||||||
has_media?: boolean; // Whether this Player provides audio/video streams
|
has_media?: boolean; // Whether this Player provides audio/video streams
|
||||||
|
color?: string;
|
||||||
bot_run_id?: string;
|
bot_run_id?: string;
|
||||||
bot_provider_id?: string;
|
bot_provider_id?: string;
|
||||||
bot_instance_id?: string; // For bot instances
|
bot_instance_id?: string; // For bot instances
|
||||||
@ -24,6 +26,27 @@ const PlayerList: React.FC = () => {
|
|||||||
const { session, socketUrl, lastJsonMessage, sendJsonMessage } = useContext(GlobalContext);
|
const { session, socketUrl, lastJsonMessage, sendJsonMessage } = useContext(GlobalContext);
|
||||||
const [players, setPlayers] = useState<Player[] | null>(null);
|
const [players, setPlayers] = useState<Player[] | null>(null);
|
||||||
const [peers, setPeers] = useState<Record<string, Peer>>({});
|
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(
|
const sortPlayers = useCallback(
|
||||||
(A: any, B: any) => {
|
(A: any, B: any) => {
|
||||||
@ -124,6 +147,18 @@ const PlayerList: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [players, sendJsonMessage]);
|
}, [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 (
|
return (
|
||||||
<Box sx={{ position: "relative", width: "100%" }}>
|
<Box sx={{ position: "relative", width: "100%" }}>
|
||||||
<Paper
|
<Paper
|
||||||
@ -134,7 +169,7 @@ const PlayerList: React.FC = () => {
|
|||||||
m: { xs: 0, sm: 2 },
|
m: { xs: 0, sm: 2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MediaAgent {...{ session, socketUrl, peers, setPeers }} />
|
<MediaAgent {...{ session, peers, setPeers }} />
|
||||||
<List className="PlayerSelector">
|
<List className="PlayerSelector">
|
||||||
{players?.map((player) => (
|
{players?.map((player) => (
|
||||||
<Box
|
<Box
|
||||||
@ -164,15 +199,53 @@ const PlayerList: React.FC = () => {
|
|||||||
{player.name && !player.live && <div className="NoNetwork"></div>}
|
{player.name && !player.live && <div className="NoNetwork"></div>}
|
||||||
</Box>
|
</Box>
|
||||||
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? (
|
{player.name && player.live && peers[player.session_id] && (player.local || player.has_media !== false) ? (
|
||||||
<MediaControl
|
<>
|
||||||
className="Medium"
|
<MediaControl
|
||||||
key={player.session_id}
|
className="Medium"
|
||||||
peer={peers[player.session_id]}
|
key={player.session_id}
|
||||||
isSelf={player.local}
|
peer={peers[player.session_id]}
|
||||||
sendJsonMessage={player.local ? sendJsonMessage : undefined}
|
isSelf={player.local}
|
||||||
remoteAudioMuted={peers[player.session_id].muted}
|
sendJsonMessage={player.local ? sendJsonMessage : undefined}
|
||||||
remoteVideoOff={peers[player.session_id].video_on === false}
|
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 ? (
|
) : player.name && player.live && player.has_media === false ? (
|
||||||
<div
|
<div
|
||||||
className="Video fade-in"
|
className="Video fade-in"
|
||||||
|
@ -168,6 +168,14 @@ const RoomView = (props: RoomProps) => {
|
|||||||
const priv = data.update.private;
|
const priv = data.update.private;
|
||||||
if (priv.name !== name) {
|
if (priv.name !== name) {
|
||||||
setName(priv.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) {
|
if (priv.color !== color) {
|
||||||
setColor(priv.color);
|
setColor(priv.color);
|
||||||
@ -178,6 +186,13 @@ const RoomView = (props: RoomProps) => {
|
|||||||
if ("name" in data.update) {
|
if ("name" in data.update) {
|
||||||
if (data.update.name) {
|
if (data.update.name) {
|
||||||
setName(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 {
|
} else {
|
||||||
setWarning("");
|
setWarning("");
|
||||||
setError("");
|
setError("");
|
||||||
@ -284,7 +299,7 @@ const RoomView = (props: RoomProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage }}>
|
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage, readyState }}>
|
||||||
<div className="RoomView">
|
<div className="RoomView">
|
||||||
{!name ? (
|
{!name ? (
|
||||||
<Paper>
|
<Paper>
|
||||||
|
131
client/src/plugins/vite-console-forward-plugin.js
Normal file
131
client/src/plugins/vite-console-forward-plugin.js
Normal 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;
|
@ -1,20 +1,34 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires, no-undef, @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-var-requires, no-undef, @typescript-eslint/no-explicit-any */
|
||||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
module.exports = function(app) {
|
module.exports = function(app) {
|
||||||
const base = process.env.PUBLIC_URL;
|
const base = process.env.PUBLIC_URL || '';
|
||||||
console.log(`http-proxy-middleware ${base}`);
|
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(
|
app.use(createProxyMiddleware(
|
||||||
`${base}/api/v1/games/ws`, {
|
`${base}/api/v1/games/ws`, {
|
||||||
ws: true,
|
ws: true,
|
||||||
target: 'ws://pok-server:8930',
|
target: 'ws://pok-server:8930',
|
||||||
changeOrigin: true,
|
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}`]: '' },
|
pathRewrite: { [`^${base}`]: '' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.use(createProxyMiddleware(
|
app.use(createProxyMiddleware(
|
||||||
`${base}/api`, {
|
`${base}/api`, {
|
||||||
target: 'http://pok-server:8930',
|
target: 'http://pok-server:8930',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
// give HTTP API calls a longer timeout in dev
|
||||||
|
proxyTimeout: 120000,
|
||||||
|
timeout: 120000,
|
||||||
pathRewrite: { [`^${base}`]: '' },
|
pathRewrite: { [`^${base}`]: '' },
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
@ -5,6 +5,10 @@ import fs from 'fs';
|
|||||||
const httpsEnv = (process.env.HTTPS || '').toLowerCase();
|
const httpsEnv = (process.env.HTTPS || '').toLowerCase();
|
||||||
const useHttps = httpsEnv === 'true' || httpsEnv === '1';
|
const useHttps = httpsEnv === 'true' || httpsEnv === '1';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
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.
|
// 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: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tsconfigPaths(),
|
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
|
// Dev-only plugin: when the dev server receives requests that are
|
||||||
// already prefixed with the base (e.g. /ketr.ketran/assets/...), strip
|
// already prefixed with the base (e.g. /ketr.ketran/assets/...), strip
|
||||||
// the prefix so Vite can serve the underlying files from /assets/...
|
// the prefix so Vite can serve the underlying files from /assets/...
|
||||||
{
|
{
|
||||||
name: 'strip-basepath-for-dev',
|
name: "strip-basepath-for-dev",
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
// Only install the middleware when a non-root base is configured
|
// 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) => {
|
server.middlewares.use((req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// Log incoming base-prefixed requests for debugging only. Do NOT
|
// 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
|
// asset paths here to avoid interfering with module paths
|
||||||
// and HMR endpoints which Vite already serves correctly
|
// and HMR endpoints which Vite already serves correctly
|
||||||
// when the server `base` is configured.
|
// when the server `base` is configured.
|
||||||
const assetsPrefix = normalizedBase.replace(/\/$/, '') + '/assets/';
|
const assetsPrefix = normalizedBase.replace(/\/$/, "") + "/assets/";
|
||||||
if (req.url.indexOf(assetsPrefix) === 0) {
|
if (req.url.indexOf(assetsPrefix) === 0) {
|
||||||
const original = req.url;
|
const original = req.url;
|
||||||
// Preserve the base and change '/assets/' to '/gfx/' so the
|
// Preserve the base and change '/assets/' to '/gfx/' so the
|
||||||
// dev server serves files from public/gfx which are exposed at
|
// dev server serves files from public/gfx which are exposed at
|
||||||
// '/<base>/gfx/...'. Example: '/ketr.ketran/assets/gfx/x' ->
|
// '/<base>/gfx/...'. Example: '/ketr.ketran/assets/gfx/x' ->
|
||||||
// '/ketr.ketran/gfx/x'.
|
// '/ketr.ketran/gfx/x'.
|
||||||
const baseNoTrail = normalizedBase.replace(/\/$/, '');
|
const baseNoTrail = normalizedBase.replace(/\/$/, "");
|
||||||
req.url = req.url.replace(new RegExp('^' + baseNoTrail + '/assets/'), baseNoTrail + '/gfx/');
|
req.url = req.url.replace(new RegExp("^" + baseNoTrail + "/assets/"), baseNoTrail + "/gfx/");
|
||||||
console.log(`[vite] rewritten asset ${original} -> ${req.url}`);
|
console.log(`[vite] rewritten asset ${original} -> ${req.url}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,41 +94,41 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'build',
|
outDir: "build",
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: process.env.HOST || '0.0.0.0',
|
host: process.env.HOST || "0.0.0.0",
|
||||||
port: Number(process.env.PORT) || 3001,
|
port: Number(process.env.PORT) || 3001,
|
||||||
https: httpsOption,
|
https: httpsOption,
|
||||||
proxy: {
|
proxy: {
|
||||||
// Support requests that already include the basePath (/ketr.ketran/api)
|
// Support requests that already include the basePath (/ketr.ketran/api)
|
||||||
// and requests that use the shorter /api path. Both should be forwarded
|
// and requests that use the shorter /api path. Both should be forwarded
|
||||||
// to the backend server which serves the API under /ketr.ketran/api.
|
// to the backend server which serves the API under /ketr.ketran/api.
|
||||||
'/ketr.ketran/api': {
|
"/ketr.ketran/api": {
|
||||||
target: 'http://pok-server:8930',
|
target: "http://pok-server:8930",
|
||||||
changeOrigin: true,
|
|
||||||
ws: true,
|
|
||||||
secure: false
|
|
||||||
},
|
|
||||||
'/api': {
|
|
||||||
target: 'http://pok-server:8930',
|
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
secure: false,
|
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
|
// HMR options: advertise the external hostname and port so browsers
|
||||||
// accessing via `battle-linux.ketrenos.com` can connect to the websocket.
|
// accessing via `battle-linux.ketrenos.com` can connect to the websocket.
|
||||||
// The certs mounted into the container must be trusted by the browser.
|
// The certs mounted into the container must be trusted by the browser.
|
||||||
hmr: {
|
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,
|
port: Number(process.env.VITE_HMR_PORT) || 3001,
|
||||||
protocol: process.env.VITE_HMR_PROTOCOL || 'wss'
|
protocol: process.env.VITE_HMR_PROTOCOL || "wss",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,28 +1,73 @@
|
|||||||
/* monkey-patch console.log to prefix with file/line-number */
|
/* monkey-patch console methods to prefix messages with file:line for easier logs */
|
||||||
if (process.env['LOG_LINE']) {
|
(() => {
|
||||||
let cwd = process.cwd(),
|
const cwd = process.cwd();
|
||||||
cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "\/([^:]*:[0-9]*).*$");
|
const cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "/([^:]*:[0-9]*).*$");
|
||||||
[ "log", "warn", "error" ].forEach(function(method: string) {
|
const methods = ["log", "warn", "error", "info", "debug"] as const;
|
||||||
(console as any)[method] = (function () {
|
|
||||||
let orig = (console as any)[method];
|
function getCallerFileLine(): string {
|
||||||
return function (this: any, ...args: any[]) {
|
try {
|
||||||
function getErrorObject(): Error {
|
// Create an Error to capture stack
|
||||||
try {
|
const err = new Error();
|
||||||
throw Error('');
|
if (!err.stack) return "unknown:0 -";
|
||||||
} catch (err) {
|
const lines = err.stack.split("\n").slice(1);
|
||||||
return err as Error;
|
// 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(),
|
// For each Error, print each line of its stack as a separate prefixed log
|
||||||
caller_line = err.stack?.split("\n")[3] || '',
|
// entry so lines that begin with ' at' are not orphaned.
|
||||||
prefixedArgs = [caller_line.replace(cwdRe, "$1 -")];
|
errorArgs.forEach((err) => {
|
||||||
|
const stack = err.stack || err.toString();
|
||||||
/* arguments.unshift() doesn't exist... */
|
stack.split("\n").forEach((line) => {
|
||||||
prefixedArgs.push(...args);
|
orig.apply(this, [prefix, line]);
|
||||||
|
});
|
||||||
orig.apply(this, prefixedArgs);
|
});
|
||||||
};
|
} catch (e) {
|
||||||
})();
|
try {
|
||||||
|
orig.apply(this, args);
|
||||||
|
} catch (e2) {
|
||||||
|
/* swallow */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
})();
|
||||||
|
@ -19,6 +19,14 @@ import { getValidRoads, getValidCorners, isRuleEnabled } from "../util/validLoca
|
|||||||
import { Player, Game, Session, CornerPlacement, RoadPlacement, Offer, Turn } from "./games/types";
|
import { Player, Game, Session, CornerPlacement, RoadPlacement, Offer, Turn } from "./games/types";
|
||||||
import { newPlayer } from "./games/playerFactory";
|
import { newPlayer } from "./games/playerFactory";
|
||||||
import { normalizeIncoming, shuffleArray } from "./games/utils";
|
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
|
// import type { GameState } from './games/state'; // unused import removed during typing pass
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -51,7 +59,9 @@ initGameDB()
|
|||||||
// shuffleArray imported from './games/utils.ts'
|
// shuffleArray imported from './games/utils.ts'
|
||||||
|
|
||||||
const games: Record<string, Game> = {};
|
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 => {
|
const processTies = (players: Player[]): boolean => {
|
||||||
/* Sort the players into buckets based on their
|
/* 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;
|
session.name = name;
|
||||||
if (session.ws && game.id in audio && session.name in audio[game.id]) {
|
if (session.ws && game.id in audio && session.name in audio[game.id]) {
|
||||||
part(audio[game.id], session);
|
webrtcPart(audio[game.id], session);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message = `${session.name} has changed their name to ${name}.`;
|
message = `${session.name} has changed their name to ${name}.`;
|
||||||
if (session.ws && game.id in audio) {
|
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) {
|
if (session.ws && session.hasAudio) {
|
||||||
join(audio[game.id], session, {
|
webrtcJoin(audio[game.id], session, {
|
||||||
hasVideo: session.video ? true : false,
|
hasVideo: session.video ? true : false,
|
||||||
hasAudio: session.audio ? 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);
|
//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 => {
|
// WebRTC join/part handling moved to server/routes/webrtc-signaling.ts
|
||||||
const ws = session.ws;
|
// use webrtcJoin(audio[gameId], session, config) and webrtcPart(audio[gameId], session)
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getName = (session: any): string => {
|
const getName = (session: any): string => {
|
||||||
return session ? (session.name ? session.name : session.id) : "Admin";
|
return session ? (session.name ? session.name : session.id) : "Admin";
|
||||||
@ -4262,7 +4109,7 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
/* Cleanup any voice channels */
|
/* Cleanup any voice channels */
|
||||||
if (gameId in audio) {
|
if (gameId in audio) {
|
||||||
try {
|
try {
|
||||||
part(audio[gameId], session);
|
webrtcPart(audio[gameId], session);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`${short}: Error during part():`, 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
|
// Clean up peer from audio registry before replacing WebSocket
|
||||||
if (gameId in audio) {
|
if (gameId in audio) {
|
||||||
try {
|
try {
|
||||||
part(audio[gameId], session);
|
webrtcPart(audio[gameId], session);
|
||||||
console.log(`${short}: Cleaned up peer ${session.name} from audio registry during reconnection`);
|
console.log(`${short}: Cleaned up peer ${session.name} from audio registry during reconnection`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`${short}: Error cleaning up peer during reconnection:`, e);
|
console.warn(`${short}: Error cleaning up peer during reconnection:`, e);
|
||||||
@ -4396,75 +4243,25 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
case "join":
|
case "join":
|
||||||
// Accept either legacy `config` or newer `data` field from clients
|
// 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;
|
break;
|
||||||
|
|
||||||
case "part":
|
case "part":
|
||||||
part(audio[gameId], session);
|
webrtcPart(audio[gameId], session);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "relayICECandidate":
|
case "relayICECandidate":
|
||||||
{
|
{
|
||||||
if (!(gameId in audio)) {
|
// Delegate to the webrtc signaling helper (it performs its own checks)
|
||||||
console.error(`${session.id}:${id} <- relayICECandidate - Does not have Audio`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support both { config: {...} } and { data: {...} } client payloads
|
|
||||||
const cfg = data.config || data.data || {};
|
const cfg = data.config || data.data || {};
|
||||||
const { peer_id, candidate } = cfg;
|
handleRelayICECandidate(gameId, cfg, session, undefined, debug);
|
||||||
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 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "relaySessionDescription":
|
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 cfg = data.config || data.data || {};
|
||||||
const { peer_id, session_description } = cfg;
|
handleRelaySessionDescription(gameId, cfg, session, undefined, debug);
|
||||||
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 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -4479,42 +4276,8 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
|
|
||||||
case "peer_state_update":
|
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 cfg = data.config || data.data || {};
|
||||||
const { muted, video_on } = cfg;
|
broadcastPeerStateUpdate(gameId, cfg, session, undefined);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
317
server/routes/webrtc-signaling.ts
Normal file
317
server/routes/webrtc-signaling.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user