472 lines
11 KiB
TypeScript
472 lines
11 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<
|
|
"peerId" | string,
|
|
string | number | symbol | null | boolean
|
|
>;
|
|
type ServerData = ServerDataObject[] | ServerDataObject | string | boolean;
|
|
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: ID;
|
|
peerId: number | null;
|
|
name: string;
|
|
socket: WebSocket;
|
|
lobby: Lobby | null;
|
|
|
|
constructor(socket: WebSocket) {
|
|
this.id = crypto.randomUUID();
|
|
this.socket = socket;
|
|
this.peerId = null;
|
|
this.name = "Client";
|
|
this.lobby = null;
|
|
|
|
allClients.set(this.id, this);
|
|
}
|
|
|
|
isConnected() {
|
|
return this.socket.readyState == WebSocket.OPEN;
|
|
}
|
|
|
|
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) {
|
|
this.send(
|
|
buildMessage(
|
|
"info",
|
|
"you cannot request a list of peers unless you are in a lobby",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const peers: {
|
|
id: ID;
|
|
name: string;
|
|
peerId: number | null;
|
|
}[] = [];
|
|
this.lobby.clients.forEach(({ id, name, peerId }) =>
|
|
peers.push({ id, name, peerId })
|
|
);
|
|
this.send(buildMessage("peer_data", peers));
|
|
// TODO: chunk async?
|
|
}
|
|
|
|
lobbyList() {
|
|
const netLobbies: {
|
|
id: ID;
|
|
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({ id }: Lobby) {
|
|
if (this.lobby) return;
|
|
this.send(buildMessage("lobby_delete", { id }));
|
|
}
|
|
|
|
lobbyCreate(opts?: Partial<Lobby>) {
|
|
if (this.lobby) {
|
|
this.send(
|
|
buildMessage(
|
|
"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(
|
|
buildMessage(
|
|
`info`,
|
|
`cannot join lobby: already in lobby ${this.lobby.id}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
this.lobby = lobby;
|
|
lobby.addClient(this);
|
|
this.send(
|
|
buildMessage("lobby_joined", {
|
|
...this.lobby.toData(),
|
|
peerId: this.peerId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
lobbyLeave() {
|
|
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();
|
|
}
|
|
|
|
toData() {
|
|
return {
|
|
id: this.id,
|
|
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.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("init_peer", client.peerId.toString()));
|
|
this.broadcast(
|
|
buildMessage("peer_data", [{
|
|
id: client.id,
|
|
name: client.name,
|
|
peerId: client.peerId,
|
|
}]),
|
|
);
|
|
client.send(
|
|
buildMessage(
|
|
"peer_data",
|
|
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();
|
|
}
|
|
|
|
getPeer(peerId: number): Client | null {
|
|
for (const [_id, client] of this.clients) {
|
|
if (client.peerId == peerId) 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?
|
|
const msg = parseMessage(ev.data);
|
|
if (msg.type === "pong") return;
|
|
console.log("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,
|
|
peerId: client.peerId,
|
|
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 { peerId?: number });
|
|
if (!webrtcMessage.peerId || typeof webrtcMessage.peerId !== "number") {
|
|
return;
|
|
}
|
|
console.log(
|
|
`Received WebRTC Negotiation Message (type: ${msg.type}, from: ${client.peerId}, to: ${webrtcMessage.peerId})...`,
|
|
);
|
|
const destClient = client.lobby.getPeer(webrtcMessage.peerId);
|
|
if (!destClient || !destClient.peerId) return;
|
|
webrtcMessage.peerId = client.peerId 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.id);
|
|
}
|
|
|
|
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" },
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
})();
|
|
}
|