457 lines
12 KiB
TypeScript
457 lines
12 KiB
TypeScript
// import { randomInt } from "./deps.ts";
|
|
|
|
const SERVER_VERSION = "0.2.0";
|
|
// TODO: version comparison
|
|
|
|
type ID = string;
|
|
|
|
// app state
|
|
const allLobbies = new Map<ID, Lobby>();
|
|
const allClients = new Map<ID, Client>();
|
|
// TODO: client index by id
|
|
|
|
interface DataMessage {
|
|
type: string;
|
|
}
|
|
|
|
type ServerDataObject = Record<
|
|
string,
|
|
string | number | symbol | null | boolean
|
|
>;
|
|
type ServerData = ServerDataObject[] | ServerDataObject | string | boolean;
|
|
type Message = string | DataMessage;
|
|
|
|
const buildMessage = (
|
|
type: string,
|
|
data: ServerData | ServerData[],
|
|
) =>
|
|
Object.assign(
|
|
{ type },
|
|
Array.isArray(data)
|
|
? { data }
|
|
: (typeof data === "object" ? data : { data }),
|
|
);
|
|
|
|
class Client {
|
|
id: ID;
|
|
peerId: number | null;
|
|
name: string;
|
|
socket: WebSocket;
|
|
lobby: Lobby | null;
|
|
ready: boolean;
|
|
|
|
constructor(socket: WebSocket) {
|
|
this.id = crypto.randomUUID();
|
|
this.socket = socket;
|
|
this.peerId = null;
|
|
this.name = "Client";
|
|
this.lobby = null;
|
|
this.ready = false;
|
|
|
|
allClients.set(this.id, this);
|
|
}
|
|
|
|
isConnected() {
|
|
return this.socket.readyState == WebSocket.OPEN;
|
|
}
|
|
|
|
setReady(ready: boolean) {
|
|
this.ready = ready;
|
|
this.lobby?.broadcast(
|
|
buildMessage("ready_change", { id: this.id, ready: this.ready }),
|
|
);
|
|
}
|
|
|
|
remove() {
|
|
this.lobbyLeave();
|
|
if (this.isConnected()) {
|
|
this.socket.close();
|
|
}
|
|
allClients.delete(this.id);
|
|
}
|
|
|
|
send(message: Message) {
|
|
try {
|
|
if (this.isConnected()) {
|
|
this.socket.send(
|
|
typeof message === "object"
|
|
? ("json:" + JSON.stringify(message))
|
|
: message,
|
|
);
|
|
}
|
|
} 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) return;
|
|
const peers: {
|
|
id: ID;
|
|
name: string;
|
|
peerId: number | null;
|
|
ready: boolean;
|
|
}[] = [];
|
|
this.lobby.clients.forEach(({ id, name, peerId, ready }) =>
|
|
peers.push({ id, name, peerId, ready })
|
|
);
|
|
this.send(buildMessage("peer_joined", peers));
|
|
// TODO: chunk async?
|
|
}
|
|
|
|
lobbyList() {
|
|
const netLobbies: {
|
|
id: ID;
|
|
name: string;
|
|
maxPlayers: number;
|
|
locked: boolean;
|
|
currentPlayers: number;
|
|
}[] = [];
|
|
allLobbies.forEach((lobby) => {
|
|
const { id, name, maxPlayers, locked } = lobby;
|
|
netLobbies.push({
|
|
id,
|
|
name,
|
|
maxPlayers,
|
|
locked,
|
|
currentPlayers: lobby.clients.size,
|
|
});
|
|
});
|
|
// TODO: chunk async?
|
|
this.send(buildMessage("lobby_list", netLobbies));
|
|
}
|
|
|
|
lobbyNew(lobby: Lobby) {
|
|
// if the client is already in a lobby, only send messages about their lobby
|
|
if (this.lobby && this.lobby != lobby) return;
|
|
const { id, name, maxPlayers, locked } = lobby;
|
|
this.send(
|
|
buildMessage("lobby_new", {
|
|
id,
|
|
name,
|
|
maxPlayers,
|
|
locked,
|
|
currentPlayers: lobby.clients.size,
|
|
}),
|
|
);
|
|
}
|
|
|
|
lobbyDelete({ id }: Lobby) {
|
|
if (this.lobby) return;
|
|
this.send(buildMessage("lobby_delete", { id }));
|
|
}
|
|
|
|
lobbyCreate(opts?: Partial<Lobby>) {
|
|
this.ready = true;
|
|
if (this.lobby) {
|
|
this.send(
|
|
`[info] cannot create lobby (already in lobby ${this.lobby.id})`,
|
|
);
|
|
return;
|
|
}
|
|
new Lobby(this, opts?.name || `${this.name}'s lobby`, opts?.maxPlayers);
|
|
}
|
|
|
|
lobbyJoin(lobby: Lobby) {
|
|
if (this.lobby) {
|
|
this.send(`[info] cannot join lobby (already in lobby ${this.lobby.id})`);
|
|
return;
|
|
}
|
|
this.lobby = lobby;
|
|
lobby.addClient(this);
|
|
this.send(
|
|
buildMessage("lobby_joined", {
|
|
id: lobby.id,
|
|
name: lobby.name,
|
|
peerId: this.peerId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
lobbyLeave() {
|
|
this.ready = false;
|
|
const leavingLobby = this.lobby;
|
|
if (!leavingLobby) {
|
|
this.send(`[info] cannot leave lobby (not in a lobby)`);
|
|
return;
|
|
}
|
|
this.peerId = null;
|
|
this.lobby = null;
|
|
if (this.isConnected()) {
|
|
this.send(buildMessage("lobby_left", { id: leavingLobby.id }));
|
|
}
|
|
leavingLobby.removeClient(this);
|
|
}
|
|
}
|
|
|
|
class Lobby {
|
|
id: ID;
|
|
name: string;
|
|
clients: Map<ID, Client>;
|
|
hostClientId: ID;
|
|
maxPlayers: number;
|
|
locked: boolean;
|
|
|
|
_shouldNotify: boolean;
|
|
|
|
constructor(host: Client, name?: string, maxPlayers = 20) {
|
|
this.id = crypto.randomUUID();
|
|
this.hostClientId = host.id;
|
|
this.clients = new Map<ID, Client>();
|
|
this.name = name || this.id;
|
|
this.maxPlayers = maxPlayers;
|
|
this.locked = false;
|
|
|
|
this._shouldNotify = true;
|
|
|
|
allLobbies.set(this.id, this);
|
|
host.peerId = 1;
|
|
host.lobbyJoin(this);
|
|
this.notify();
|
|
}
|
|
|
|
update(
|
|
requestor: Client,
|
|
newValues: { name?: string; maxPlayers?: number; locked?: boolean },
|
|
) {
|
|
if (requestor.peerId === 1) {
|
|
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;
|
|
allLobbies.delete(this.id);
|
|
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.peerId) {
|
|
const arr = new Int32Array(1);
|
|
crypto.getRandomValues(arr);
|
|
client.peerId = Math.abs(arr[0]);
|
|
}
|
|
client.send(buildMessage("your_peer_id", client.peerId.toString()));
|
|
this.broadcast(
|
|
buildMessage("peer_joined", [{
|
|
id: client.id,
|
|
name: client.name,
|
|
peerId: client.peerId,
|
|
}]),
|
|
);
|
|
console.log("Sending peer_joined...");
|
|
client.send(
|
|
buildMessage(
|
|
"peer_joined",
|
|
Array.from(this.clients.values()).map(
|
|
({ id, name, peerId }) => ({ id, name, peerId }),
|
|
),
|
|
),
|
|
);
|
|
this.clients.set(client.id, client);
|
|
this.notify();
|
|
}
|
|
|
|
removeClient({ id }: Client) {
|
|
this.clients.delete(id);
|
|
this.broadcast(buildMessage("peer_left", [{ id }]));
|
|
if (id === this.hostClientId) {
|
|
console.warn("Host left!");
|
|
this.remove();
|
|
}
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
interface ClientMessage {
|
|
type:
|
|
| "candidate"
|
|
| "offer"
|
|
| "answer"
|
|
| "init"
|
|
| "lobby_create"
|
|
| "update_lobby";
|
|
data: ServerDataObject;
|
|
}
|
|
|
|
// events
|
|
function onMessage(client: Client, ev: MessageEvent) {
|
|
// TODO: log who from?
|
|
const msg = ev.data.trim();
|
|
if (msg === "pong") return;
|
|
console.log("Client Message Received", msg);
|
|
if (msg === "init") {
|
|
client.send(
|
|
buildMessage("init", {
|
|
id: client.id,
|
|
name: client.name,
|
|
serverVersion: SERVER_VERSION,
|
|
}),
|
|
);
|
|
}
|
|
if (msg === "lobby_create") client.lobbyCreate();
|
|
if (msg.startsWith("set_ready:")) {
|
|
client.setReady(JSON.parse(msg.split(":", 2)[1]));
|
|
}
|
|
if (msg === "lobby_leave") client.lobbyLeave();
|
|
if (msg === "request_lobby_list") client.lobbyList();
|
|
if (msg === "request_peer_list") {
|
|
if (client.lobby == null) {
|
|
client.send(`[info] not in a lobby`);
|
|
} else {
|
|
client.peerList();
|
|
}
|
|
}
|
|
if (msg.startsWith("update_display_name:")) {
|
|
const displayName = msg.substr("update_display_name:".indexOf(":") + 1);
|
|
client.name = displayName;
|
|
}
|
|
if (msg.startsWith("lobby_join:")) {
|
|
const id = msg.substr(11);
|
|
const lobby = allLobbies.get(id);
|
|
if (lobby) {
|
|
client.lobbyJoin(lobby);
|
|
// client.peerList();
|
|
} else client.send(`[info] could not find lobby ${id}`);
|
|
}
|
|
if (msg.startsWith("json:")) {
|
|
const data: ClientMessage = JSON.parse(msg.substr(5));
|
|
if (data.type === "init") {
|
|
if (data.data.name) {
|
|
client.name = data.data.name.toString();
|
|
}
|
|
client.send(buildMessage("init", { id: client.id, name: client.name }));
|
|
} else if (data.type === "update_lobby") {
|
|
if (client.lobby) {
|
|
client.lobby.update(client, data["data"]);
|
|
} else {
|
|
client.send("[info] cannot update lobby (you're not even in one!)");
|
|
}
|
|
} else if (data.type === "lobby_create") {
|
|
client.lobbyCreate({ name: data.data?.name?.toString() });
|
|
if (data.data.name) {
|
|
client.name = data.data.name.toString();
|
|
}
|
|
client.send(buildMessage("init", { id: client.id, name: client.name }));
|
|
} else if (["candidate", "answer", "offer"].includes(data.type)) {
|
|
console.log("Received WebRTC Negotiation Message...");
|
|
if (typeof data.data === "object") {
|
|
const subdata = data.data;
|
|
if (typeof subdata["peerId"] === "number") {
|
|
const destPeerId: number = subdata["peerId"];
|
|
for (const iClient of client.lobby?.clients.values() || []) {
|
|
if (iClient.peerId == destPeerId) {
|
|
const payload = Object.assign({}, data);
|
|
const srcPeerId = client.peerId;
|
|
payload.data.peerId = srcPeerId;
|
|
console.log(
|
|
`Forwarding WebRTC Negotiation Message from peer ${srcPeerId} to peer ${destPeerId}...`,
|
|
);
|
|
iClient.send(payload);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onSocketOpen(client: Client, _ev: Event) {
|
|
console.log("New Client", client.id);
|
|
}
|
|
|
|
function onClientLeave(client: Client) {
|
|
client.remove();
|
|
}
|
|
|
|
function onSocketClose(client: Client, _ev: Event) {
|
|
console.log("Client Close");
|
|
onClientLeave(client);
|
|
}
|
|
|
|
function onSocketError(client: Client, _ev: Event) {
|
|
console.log("Client 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) {
|
|
// console.debug("Connection received:", conn);
|
|
(async () => {
|
|
const server = Deno.serveHttp(conn);
|
|
for await (const { respondWith, request } of server) {
|
|
// console.debug("HTTP Request Received", request);
|
|
try {
|
|
// console.warn(JSON.stringify([allClients, allLobbies]));
|
|
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" },
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
})();
|
|
}
|