godot-webrtc-mplayer-testing/server.ts

486 lines
12 KiB
TypeScript

// import { randomInt } from "./deps.ts";
const SERVER_VERSION = "0.2.0";
// TODO: version comparison
type ID = number;
type UUID = string;
// app state
const allLobbies = new Map<UUID, Lobby>();
const allClients = new Map<UUID, Client>();
// TODO: client index by id?
interface DataMessage {
type: string;
}
type ServerDataObject = Record<
"peerId" | string,
string | number | symbol | null | boolean
>;
type ServerData =
| ServerDataObject[]
| ServerDataObject
| string
| boolean
| number;
type Message = string | DataMessage;
const buildMessage = (
type: string,
data: ServerData | ServerData[],
) => {
if (Array.isArray(data)) return { type, data };
else if (typeof data === "object") return { type, ...data };
return `${type}:${data}`;
};
class Client {
id: number | null;
uuid: string;
name: string;
socket: WebSocket;
lobby: Lobby | null;
constructor(socket: WebSocket) {
this.id = null;
this.uuid = crypto.randomUUID();
this.socket = socket;
this.name = "Client";
this.lobby = null;
allClients.set(this.uuid, this);
}
isConnected() {
return this.socket.readyState == WebSocket.OPEN;
}
remove() {
console.debug(`Removing Client ${this.uuid}`);
this.lobbyLeave();
if (this.isConnected()) {
this.socket.close();
}
allClients.delete(this.uuid);
}
send(message: Message) {
try {
if (this.isConnected()) {
this.socket.send(
typeof message === "object"
? ("json:" + JSON.stringify(message))
: message,
);
}
// TODO: maybe log when we try to send and we're not connected?
} catch (e) {
console.error(
`Failed to send on socket ${this.socket} to client ${this.id}: ${
JSON.stringify(e)
}`,
);
this.remove();
}
}
ping() {
this.send(buildMessage("ping", {}));
}
peerList() {
if (!this.lobby) {
this.send(
buildMessage(
"info",
"you cannot request a list of peers unless you are in a lobby",
),
);
return;
}
const peers: {
id: ID;
name: string;
}[] = [];
this.lobby.clients.forEach(({ id, name }) => {
if (typeof id === "number") peers.push({ id, name });
});
console.debug(`Sending peer list with ${peers.length} peers...`);
this.send(buildMessage("peer_data", peers));
// TODO: chunk async?
}
lobbyList() {
const netLobbies: {
uuid: UUID;
name: string;
maxPlayers: number;
locked: boolean;
currentPlayers: number;
}[] = [];
allLobbies.forEach((lobby) => netLobbies.push(lobby.toData()));
this.send(buildMessage("lobby_data", netLobbies));
}
lobbyNew(lobby: Lobby) {
// if the client is already in a lobby, do not send them lobby updates
if (this.lobby) return;
this.send(
buildMessage("lobby_data", [lobby.toData()]),
);
}
lobbyDelete({ uuid }: Lobby) {
if (this.lobby) return;
this.send(buildMessage("lobby_delete", { uuid }));
}
lobbyCreate(opts?: Partial<Lobby>) {
if (this.lobby) {
this.send(
buildMessage(
"info",
`cannot create lobby: already in lobby ${this.lobby.uuid}`,
),
);
return;
}
new Lobby(this, opts?.name || `${this.name}'s lobby`, opts?.maxPlayers);
}
lobbyJoin(lobby: Lobby) {
if (this.lobby) {
this.send(
buildMessage(
`info`,
`cannot join lobby: already in lobby ${this.lobby.uuid}`,
),
);
return;
}
this.lobby = lobby;
lobby.addClient(this);
this.send(
buildMessage("lobby_joined", {
...this.lobby.toData(),
id: this.id,
}),
);
}
lobbyLeave() {
const leavingLobby = this.lobby;
if (!leavingLobby) {
this.send(`[info] cannot leave lobby (not in a lobby)`);
return;
}
console.debug(`Client ${this.uuid} leaving Lobby ${this.lobby?.uuid}`);
leavingLobby.removeClient(this);
this.id = null;
this.lobby = null;
this.send(buildMessage("lobby_left", { uuid: leavingLobby.uuid }));
}
}
class Lobby {
uuid: UUID;
name: string;
clients: Map<ID, Client>;
hostClientUuid: UUID;
maxPlayers: number;
locked: boolean;
_shouldNotify: boolean;
constructor(host: Client, name?: string, maxPlayers = 20) {
this.uuid = crypto.randomUUID();
this.hostClientUuid = host.uuid;
this.clients = new Map<ID, Client>();
this.name = name || this.uuid;
this.maxPlayers = maxPlayers;
this.locked = false;
this._shouldNotify = true;
console.debug(
`Lobby ${this.uuid} created by Client ${this.hostClientUuid}`,
);
allLobbies.set(this.uuid, this);
host.id = 1;
host.lobbyJoin(this);
this.notify();
}
toData() {
return {
uuid: this.uuid,
name: this.name,
maxPlayers: this.maxPlayers,
locked: this.locked,
currentPlayers: this.clients.size,
};
}
update(
requestor: Client,
newValues: { name?: string; maxPlayers?: number; locked?: boolean },
) {
if (requestor.uuid === this.hostClientUuid) {
for (const k in newValues) {
switch (k) {
case "name":
if (newValues?.name) this.name = newValues.name;
break;
case "maxPlayers":
if (
newValues?.maxPlayers && typeof newValues.maxPlayers === "number"
) {
this.maxPlayers = newValues.maxPlayers;
}
break;
case "locked":
if (newValues?.locked) this.locked = newValues.locked;
break;
}
}
this.notify();
} else {
requestor.send(
"[info] you cannot update this lobby (you are not the host!)",
);
}
}
notify() {
if (!this._shouldNotify) return;
allClients.forEach((client) => client.lobbyNew(this));
}
remove() {
this._shouldNotify = false;
console.debug(`Removing Lobby ${this.uuid}`);
allLobbies.delete(this.uuid);
allClients.forEach((client) => client.lobbyDelete(this));
this.clients.forEach((client) => {
client.lobbyLeave();
});
}
broadcast(message: Message) {
this.clients.forEach((client) => client.send(message));
}
addClient(client: Client) {
if (!client.id) {
const arr = new Int32Array(1);
crypto.getRandomValues(arr);
client.id = Math.abs(arr[0]);
}
console.debug(
`Adding Client ${client.uuid} Lobby ${this.uuid} as peer ${client.id}`,
);
client.send(buildMessage("init_peer", client.id));
this.broadcast(
buildMessage("peer_data", [{
id: client.id,
name: client.name,
}]),
);
client.send(
buildMessage(
"peer_data",
Array.from(this.clients.values()).map(
({ id, name }) => ({ id, name }),
),
),
);
this.clients.set(client.id, client);
this.notify();
}
removeClient({ id, uuid }: Client) {
if (typeof id !== "number") return;
this.clients.delete(id);
this.broadcast(buildMessage("peer_left", { id }));
console.debug(`Removing Client ${this.uuid} from Lobby ${this.uuid}`);
if (uuid === this.hostClientUuid) {
console.warn(`Host left, removing Lobby ${this.uuid}`);
this.remove();
}
this.notify();
}
getPeer(id: number): Client | null {
for (const [_id, client] of this.clients) {
if (client.id == id) return client;
}
return null;
}
}
interface ClientMessage {
type:
| "candidate"
| "offer"
| "answer"
| "init"
| "lobby_create"
| "update_lobby";
data: ServerDataObject;
}
function parseMessage(message: string): { type: string; data?: ServerData } {
const trimmedMessage = message.trim();
if (trimmedMessage.startsWith("json:")) {
const { type, ...data } = JSON.parse(trimmedMessage.substr(5));
return { type, data };
} else {
const splitAt = trimmedMessage.indexOf(":");
if (splitAt > 0) {
return {
type: trimmedMessage.substr(0, splitAt),
data: trimmedMessage.substr(splitAt + 1),
};
} else {
return { type: trimmedMessage };
}
}
}
// events
function onMessage(client: Client, ev: MessageEvent) {
// TODO: log who from? IPs etc.?
const msg = parseMessage(ev.data);
if (msg.type === "pong") return;
console.debug("Client Message Received", msg);
switch (msg.type) {
case "init":
if (msg.data && (msg.data as { name: string })["name"]) {
client.name = (msg.data as { name: string }).name.toString();
}
client.send(buildMessage("init", {
id: client.id,
name: client.name,
serverVersion: SERVER_VERSION,
}));
break;
case "lobby_create":
client.lobbyCreate(msg.data as Partial<Lobby>);
break;
case "lobby_leave":
client.lobbyLeave();
break;
case "request_lobby_list":
client.lobbyList();
break;
case "request_peer_list":
client.peerList();
break;
case "update_display_name":
if (msg.data) client.name = msg.data.toString();
break;
case "lobby_join":
if (msg.data) {
const lobby = allLobbies.get(msg.data.toString());
if (lobby) {
client.lobbyJoin(lobby);
} else {
client.send(buildMessage("info", `count not find lobby ${msg.data}`));
}
}
break;
case "update_lobby":
if (client.lobby) {
if (typeof (msg.data) === "object") {
client.lobby.update(client, msg.data as Partial<Lobby>);
}
} else {
client.send(
buildMessage(
"info",
"failed to update lobby info: you are not in a lobby",
),
);
}
break;
case "candidate":
/* falls through */
case "answer":
/* falls through */
case "offer": {
if (!client.lobby) return;
if (typeof msg.data !== "object") return;
const webrtcMessage = (msg.data as { id?: number });
if (!webrtcMessage.id || typeof webrtcMessage.id !== "number") {
return;
}
console.log(
`Received WebRTC Negotiation Message (type: ${msg.type}, from: ${client.id}, to: ${webrtcMessage.id})...`,
);
const destClient = client.lobby.getPeer(webrtcMessage.id);
if (!destClient || !destClient.id) return;
webrtcMessage.id = client.id as number;
destClient.send(buildMessage(msg.type, webrtcMessage));
break;
}
default:
console.debug("Unknown message: ", msg);
break;
}
}
function onSocketOpen(client: Client, _ev: Event) {
console.log("New Client", client.uuid);
}
function onClientLeave(client: Client) {
client.remove();
}
function onSocketClose(client: Client, _ev: Event) {
console.log("Client Socket Close");
onClientLeave(client);
}
function onSocketError(client: Client, _ev: Event) {
console.log("Client Socket Error");
onClientLeave(client);
}
setInterval(() => {
allLobbies.forEach((lobby) => {
if (lobby.clients.size <= 0) lobby.remove();
});
allClients.forEach((client) => client.ping());
}, 5000);
const PORT = parseInt(Deno.env.get("PORT") || "80");
console.log("Listening on port", PORT);
const listener = Deno.listen({ port: PORT });
for await (const conn of listener) {
(async () => {
const server = Deno.serveHttp(conn);
for await (const { respondWith, request } of server) {
try {
const { socket, response } = Deno.upgradeWebSocket(request);
const client = new Client(socket);
socket.onmessage = (ev) => onMessage(client, ev);
socket.onopen = (ev) => onSocketOpen(client, ev);
socket.onclose = (ev) => onSocketClose(client, ev);
socket.onerror = (ev) => onSocketError(client, ev);
respondWith(response);
} catch (e) {
console.log("Could not add client for unhandled reason:", e);
respondWith(
new Response("400 Bad Request", {
status: 400,
headers: { "content-type": "text/html" },
}),
);
}
}
})();
}