Initial lobby server + WebRTC networking
parent
efc649bd97
commit
c7cdf2664c
@ -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…
Reference in New Issue