Initial lobby server + WebRTC networking

main
idylls 1 year ago
parent efc649bd97
commit c7cdf2664c
Signed by: idylls
GPG Key ID: 8A7167CBC2CC9F0F

2
.gitignore vendored

@ -0,0 +1,2 @@
build/
build/*

@ -0,0 +1,25 @@
import { emptyDir } from "https://deno.land/std@0.162.0/fs/mod.ts";
import sass from "https://deno.land/x/denosass@1.0.6/mod.ts";
import { build, stop } from "https://deno.land/x/esbuild@v0.17.5/mod.js";
import { httpImports } from "https://deno.land/x/esbuild_plugin_http_imports@v1.2.4/index.ts";
await emptyDir("./build");
const compiler = sass(["./ui/style.scss"]);
compiler.to_file({
destDir: "./build/",
destFile: "style",
format: "compressed",
});
const res = await build({
entryPoints: ["./ui/main.ts"],
write: true,
bundle: true,
outfile: "./build/app.js",
sourcemap: "inline",
plugins: [httpImports()],
});
console.debug(res);
stop();

@ -0,0 +1,4 @@
#!/bin/sh
deno run -A _build_ui.ts
cp ui/app.html build/index.html

@ -0,0 +1,29 @@
export class BiMap<A, B> {
#aToB: Map<A, B> = new Map();
#bToA: Map<B, A> = new Map();
set(a: A, b: B) {
this.#aToB.set(a, b);
this.#bToA.set(b, a);
}
getByA(a: A) {
return this.#aToB.get(a);
}
getByB(b: B) {
return this.#bToA.get(b);
}
removeByA(a: A) {
const b = this.getByA(a);
this.#aToB.delete(a);
this.#bToA.delete(b!);
}
removeByB(b: B) {
const a = this.getByB(b);
this.#bToA.delete(b);
this.#aToB.delete(a!);
}
}

@ -0,0 +1,42 @@
export type LobbyID = string & { readonly __lobby_id: unique symbol };
export type PeerID = string & { readonly __peer_id: unique symbol };
export type JoinLobby = { kind: "joinLobby"; lobbyID?: LobbyID };
export type LobbyRequest =
| JoinLobby
| ConnectOffer
| ConnectAnswer
| ICECandidate;
export type Registered = {
kind: "registered";
peerID: PeerID;
};
export type JoinedLobby = {
kind: "joinedLobby";
lobbyID: LobbyID;
peers: PeerID[];
};
export type ConnectOffer = {
kind: "connectOffer";
peerID: PeerID;
offer: RTCSessionDescriptionInit;
};
export type ConnectAnswer = {
kind: "connectAnswer";
peerID: PeerID;
answer: RTCSessionDescriptionInit;
};
export type ICECandidate = {
kind: "iceCandidate";
peerID: PeerID;
candidate: RTCIceCandidateInit;
};
export type LobbyError = { error: string };
export type LobbyResponse =
| Registered
| JoinedLobby
| ConnectAnswer
| ConnectOffer
| ICECandidate
| LobbyError;

@ -0,0 +1,11 @@
module.exports = {
printWidth: 80,
useTabs: true,
semi: true,
singleQuote: false,
quoteProps: "as-needed",
trailingComma: "all",
bracketSpacing: true,
arrowParens: "always",
parser: "typescript",
};

@ -0,0 +1,3 @@
#!/bin/sh
deno run --allow-net --allow-env ./server/main.ts

@ -0,0 +1 @@
export { serve } from "https://deno.land/std@0.175.0/http/mod.ts";

@ -0,0 +1,210 @@
import { serve } from "./deps/std.ts";
import {
ConnectAnswer,
ConnectOffer,
ICECandidate,
LobbyID,
LobbyRequest,
LobbyResponse,
PeerID,
} from "../common/types.ts";
import { BiMap } from "../common/bimap.ts";
type Peer = WebSocket;
type Lobby = { peers: Set<PeerID> };
const lobbies: Map<LobbyID, Lobby> = new Map();
const peers: BiMap<Peer, PeerID> = new BiMap();
const getPeerID = (peer: Peer) => peers.getByA(peer);
const peerLobbies: Map<PeerID, LobbyID> = new Map();
const getPeerLobby = (peer: PeerID) => {
const lobbyID = peerLobbies.get(peer);
if (!lobbyID) {
return undefined;
}
const lobby = lobbies.get(lobbyID)!;
return { lobbyID, lobby };
};
const generateID = <T extends string>(): T => crypto.randomUUID() as T;
const generatePeerID = generateID<PeerID>;
const generateLobbyID = generateID<LobbyID>;
const send = (peer: Peer, response: LobbyResponse) => {
peer.send(
JSON.stringify(response, (key, value) => {
if (value instanceof Set) {
return [...value.values()];
}
return value;
}),
);
};
const createLobby = (host: Peer, hostID: PeerID) => {
const lobbyID = generateLobbyID();
const lobby = {
peers: new Set([hostID]),
};
lobbies.set(lobbyID, lobby);
peerLobbies.set(hostID, lobbyID);
send(host, {
kind: "joinedLobby",
lobbyID,
peers: [...lobby.peers.values()],
});
};
const joinLobby = (peer: Peer, peerID: PeerID, lobbyID: LobbyID) => {
const lobby = lobbies.get(lobbyID);
if (!lobby) {
send(peer, { error: "Lobby not found" });
return;
}
lobby.peers.add(peerID);
peerLobbies.set(peerID, lobbyID);
send(peer, {
kind: "joinedLobby",
lobbyID,
peers: [...lobby.peers.values()],
});
};
const getOtherPeer = (thisPeerID: PeerID, otherPeerID: PeerID) => {
const peerLobby = getPeerLobby(thisPeerID);
if (!peerLobby) {
return "Not currently in a lobby";
}
const otherPeer = peers.getByB(otherPeerID);
if (!otherPeer) {
return "Peer not found";
}
if (!peerLobby.lobby.peers.has(otherPeerID)) {
return "Peer not found";
}
return otherPeer;
};
const connectOffer = (peer: Peer, peerID: PeerID, msg: ConnectOffer) => {
const otherPeer = getOtherPeer(peerID, msg.peerID);
if (typeof otherPeer === "string") {
send(peer, { error: otherPeer });
return;
}
send(otherPeer, { kind: "connectOffer", peerID, offer: msg.offer });
};
const connectAnswer = (peer: Peer, peerID: PeerID, msg: ConnectAnswer) => {
const otherPeer = getOtherPeer(peerID, msg.peerID);
if (typeof otherPeer === "string") {
send(peer, { error: otherPeer });
return;
}
send(otherPeer, { kind: "connectAnswer", peerID, answer: msg.answer });
};
const iceCandidate = (peer: Peer, peerID: PeerID, msg: ICECandidate) => {
const otherPeer = getOtherPeer(peerID, msg.peerID);
if (typeof otherPeer === "string") {
send(peer, { error: otherPeer });
return;
}
send(otherPeer, { kind: "iceCandidate", peerID, candidate: msg.candidate });
};
const handleSocketMessage = function (this: WebSocket, ev: MessageEvent) {
const data: LobbyRequest = JSON.parse(ev.data);
console.debug(data);
const peerID = getPeerID(this);
if (!peerID) {
console.error(`Message from unregistered peer: ${data}`);
return;
}
switch (data.kind) {
case "joinLobby":
if (!data.lobbyID) {
return createLobby(this, peerID);
}
return joinLobby(this, peerID, data.lobbyID);
case "connectOffer":
return connectOffer(this, peerID, data);
case "connectAnswer":
return connectAnswer(this, peerID, data);
case "iceCandidate":
return iceCandidate(this, peerID, data);
}
};
const cleanupPeer = function (this: WebSocket) {
const peerID = getPeerID(this);
if (!peerID) {
return;
}
console.log(`Cleaning up peer ${peerID}`);
peers.removeByB(peerID);
const l = getPeerLobby(peerID);
if (!l) {
return;
}
const { lobby, lobbyID } = l;
lobby.peers.delete(peerID);
if (lobby.peers.size === 0) {
lobbies.delete(lobbyID);
}
};
const handleReq = (req: Request) => {
if (req.headers.get("upgrade") != "websocket") {
return new Response(null, { status: 501 });
}
const { socket, response } = Deno.upgradeWebSocket(req);
const peerID = generatePeerID();
peers.set(socket, peerID);
socket.onopen = () => {
send(socket, { kind: "registered", peerID });
};
socket.onmessage = handleSocketMessage;
socket.onclose = cleanupPeer;
console.log(`Peer ${peerID} connected`);
return response;
};
const port = +(Deno.env.get("WALKY_SERVER_PORT") ?? 8382);
await serve(handleReq, {
port,
onError: (...args) => {
console.debug(args);
return new Response(null, { status: 500 });
},
});

@ -0,0 +1,8 @@
{
"compilerOptions": {
"lib": ["esnext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "node"
}
}

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/app.js"></script>
</head>
<body>
<main>
<noscript>Please enable JavaScript to use Walky</noscript>
<script>init()</script>
</main>
</body>
</html>

@ -0,0 +1 @@
export { swel } from "../../swel/lib.ts";

@ -0,0 +1,58 @@
import { swel } from "./deps.ts";
import { LobbyID } from "../common/types.ts";
import { initNetworking, Networking } from "./networking.ts";
const inLobby = (main: HTMLElement, net: Networking, lobbyID: LobbyID) => {
setInterval(() => {
navigator.geolocation.getCurrentPosition((p) => {
console.debug({ p });
net.broadcast(p.coords);
}, console.debug);
}, 1000);
main.replaceChildren(lobbyID);
};
const init = async () => {
const main = document.getElementsByTagName("main")[0];
const net = await initNetworking();
const host = async () => {
const lobby = await net.createLobby();
inLobby(main, net, lobby);
};
const join = async (lobbyID: LobbyID) => {
const lobby = await net.joinLobby(lobbyID);
inLobby(main, net, lobby);
};
const hostBtn = swel(
"button",
{
on: { click: host },
},
"Host",
);
const lobbyIDInput = swel("input", { placeholder: "Lobby ID" });
const joinBtn = swel(
"button",
{
on: {
click: () => {
join(lobbyIDInput.value as LobbyID);
},
},
},
"Join",
);
main.replaceChildren(hostBtn, lobbyIDInput, joinBtn);
};
(window as unknown as { init: typeof init })["init"] = init;

@ -0,0 +1,180 @@
import { BiMap } from "../common/bimap.ts";
import {
JoinedLobby,
LobbyError,
LobbyID,
LobbyRequest,
LobbyResponse,
PeerID,
} from "../common/types.ts";
const initRTCConnection = (
send: (msg: LobbyRequest) => void,
peerID: PeerID,
) => {
const rtc = new RTCPeerConnection();
rtc.onconnectionstatechange = (_event) => {
console.debug(rtc.connectionState);
};
rtc.onicecandidate = (event) => {
if (event.candidate) {
console.debug("Sending ICE candidate");
send({ kind: "iceCandidate", candidate: event.candidate, peerID });
}
};
return rtc;
};
export const initNetworking = async () => {
const lobbySocket = new WebSocket("ws://localhost:8382/sock");
const peers = new BiMap<PeerID, RTCPeerConnection>();
const dataChannels = new Map<PeerID, RTCDataChannel>();
const msgData = (msg: MessageEvent) => JSON.parse(msg.data);
const handler =
<T>(h: (v: T) => void) =>
(msg: MessageEvent) => {
const data = msgData(msg);
return h(data);
};
const send = (msg: LobbyRequest) => {
const enc = JSON.stringify(msg);
lobbySocket.send(enc);
};
type LR = Exclude<LobbyResponse, LobbyError>;
const on = {
registered: null,
connectAnswer: null,
connectOffer: null,
iceCandidate: null,
joinedLobby: null,
} as {
[K in LR["kind"]]: ((k: LR & { kind: K }) => void) | null;
};
lobbySocket.onopen = () => {
lobbySocket.addEventListener(
"message",
handler((data: LobbyResponse) => {
console.debug(data);
if ("error" in data) {
throw new Error(data.error);
}
const handler = on[data.kind];
if (handler) {
handler(data as any);
}
}),
);
};
const joinedLobby = (r: (lobbyID: LobbyID) => void, msg: JoinedLobby) => {
on.joinedLobby = null;
for (const peer of msg.peers) {
if (peer == peerID) {
continue;
}
connectToPeer(peer);
}
r(msg.lobbyID);
};
const createLobby = (): Promise<LobbyID> => {
return new Promise((r) => {
on.joinedLobby = (m) => joinedLobby(r, m);
send({ kind: "joinLobby" });
});
};
const joinLobby = (lobbyID: LobbyID): Promise<LobbyID> => {
return new Promise((r) => {
on.joinedLobby = (m) => joinedLobby(r, m);
send({ kind: "joinLobby", lobbyID });
});
};
const connectToPeer = async (peerID: PeerID) => {
const rtc = initRTCConnection(send, peerID);
const dc = rtc.createDataChannel("data");
dc.onmessage = console.debug;
dataChannels.set(peerID, dc);
const offer = await rtc.createOffer();
await rtc.setLocalDescription(offer);
send({ kind: "connectOffer", peerID, offer });
const answer: RTCSessionDescriptionInit = await new Promise((r) => {
on.connectAnswer = (msg) => {
on.connectAnswer = null;
r(msg.answer);
};
});
const desc = new RTCSessionDescription(answer);
await rtc.setRemoteDescription(desc);
peers.set(peerID, rtc);
};
on.connectOffer = async (msg) => {
const rtc = initRTCConnection(send, msg.peerID);
rtc.ondatachannel = (event) => {
const dc = event.channel;
dc.onmessage = console.debug;
dataChannels.set(msg.peerID, event.channel);
};
const offer = new RTCSessionDescription(msg.offer);
await rtc.setRemoteDescription(offer);
const answer = await rtc.createAnswer();
await rtc.setLocalDescription(answer);
send({ kind: "connectAnswer", answer, peerID: msg.peerID });
peers.set(msg.peerID, rtc);
};
on.iceCandidate = (msg) => {
const rtc = peers.getByA(msg.peerID);
if (!rtc) {
return;
}
console.debug("Adding ice candidate");
rtc.addIceCandidate(msg.candidate);
};
const broadcast = (data: any) => {
console.debug(dataChannels);
for (const dc of dataChannels.values()) {
dc.send(JSON.stringify(data));
}
};
const peerID: PeerID = await new Promise((r) => {
on.registered = (resp) => {
r(resp.peerID);
};
});
return {
createLobby,
joinLobby,
connectToPeer,
peerID,
broadcast,
};
};
export type Networking = Awaited<ReturnType<typeof initNetworking>>;
Loading…
Cancel
Save