Hagia
log in
morj / pokesh
overview
files
history
wiki
Viewing at
// @ts-check
"use strict";

const HTTPS_PORT = 31337;

import * as fs from "node:fs";
import * as http from "node:http";
import * as https from "node:https";
import * as WebSocket from "npm:ws";

import * as Types from "../client/types.js";

/**
* @param {string} jsonStr
* @returns {Types.ClientMessage | null}
*/
function decodeClientMessage(jsonStr) {
/** @type {unknown} */
const json = JSON.parse(jsonStr);
if (!Types.isObj(json)) {
return null;
}
if (!("body" in json && typeof json.body === "object" && json.body != null)) {
return null;
}
if (!("t" in json.body)) {
return null;
}
const targetId = ("targetId" in json && Types.isString(json.targetId)) ?
json.targetId : null;

if (json.body.t === "sdp"
&& "sdp" in json.body && Types.isObj(json.body.sdp)
&& "type" in json.body.sdp && Types.isRtcSdptype(json.body.sdp.type)
&& "sdp" in json.body.sdp && Types.isString(json.body.sdp.sdp)
) {
return {
targetId,
body: {
t: json.body.t,
sdp: {
type: json.body.sdp.type,
sdp: json.body.sdp.sdp,
},
},
};
} else if (json.body.t === "ice"
&& "ice" in json.body && Types.isRtcIceCandidateInit(json.body.ice)
) {
return {
targetId,
body: {
t: json.body.t,
ice: json.body.ice,
},
};
} else if (json.body.t === "joinRoom"
&& "roomId" in json.body && Types.isString(json.body.roomId)
) {
return {
targetId,
body: {
t: json.body.t,
roomId: json.body.roomId,
},
};
} else {
return null;
}
}
/**
* @param {Types.ServerMessage} msg
* @returns {string}
*/
function encodeServerMessage(msg) {
return JSON.stringify(msg);
}

/**
* @param {never} x
* @returns {never}
*/
function unreachable(x) {
panic(`Unreachable reached: ${x}`);
}

/**
* @param {string} s
* @returns {never}
*/
function panic(s) {
throw new Error("Panic: " + s);
}

/**
* @template A
* @param {A | null | undefined} x
* @param {string} s - error message
* @returns {A}
*/
function expect(x, s) {
if (x == null) {
panic("unwrap: " + s);
}
return x;
}


///// Main logic /////


/** @typedef {{host: string, watchers: string[]}} Room */

const words = (await Deno.readTextFile("words.txt")).split("\n");
function makeId() {
const l = words.length;
const i1 = Math.floor(Math.random() * l);
const i2 = Math.floor(Math.random() * l);
return words[i1] + "-" + words[i2];
}

function main() {
const httpsServer = startHttpsServer();
startWebSocketServer(httpsServer);
}

/** @returns {https.Server | http.Server} */
function startHttpsServer() {
// Handle incoming requests from the client

/**
* @param {http.IncomingMessage} request
* @param {http.ServerResponse} response
*/
function handleRequest(request, response) {
if (request.url === undefined) {
console.warn("No URL in request");
return;
}
// This server only serves two files: The HTML page and the client JS file
if (request.url === "/") {
response.writeHead(200, {"Content-Type": "text/html"});
response.end(fs.readFileSync("client/host.html"));
} else if (request.url === "/host.js") {
response.writeHead(200, {"Content-Type": "application/javascript"});
response.end(fs.readFileSync("client/host.js"));
} else if (request.url === "/types.js") {
response.writeHead(200, {"Content-Type": "application/javascript"});
response.end(fs.readFileSync("client/types.js"));
} else if (request.url === "/watcher.js") {
response.writeHead(200, {"Content-Type": "application/javascript"});
response.end(fs.readFileSync("client/watcher.js"));
} else if (request.url.substring(0, 6) === "/watch") {
response.writeHead(200, {"Content-Type": "text/html"});
response.end(fs.readFileSync("client/watcher.html"));
}
};

const debugMode = Deno.env.get("DEBUG_MODE") === "true";
if (debugMode) {
const serverConfig = {
key: fs.readFileSync("key.pem"),
cert: fs.readFileSync("cert.pem"),
};
const httpsServer = https.createServer(serverConfig, handleRequest);
httpsServer.listen(HTTPS_PORT, "0.0.0.0");
console.log(`Started server at https://localhost:${HTTPS_PORT}`);
return httpsServer;
} else {
const httpServer = http.createServer({}, handleRequest);
httpServer.listen(HTTPS_PORT, "::1");
console.log(`Started server at http://localhost:${HTTPS_PORT}`);
return httpServer;
}
}

/** @param {https.Server | http.Server} httpsServer */
function startWebSocketServer(httpsServer) {
// Create a server for handling websocket calls
const wss = new WebSocket.WebSocketServer({server: httpsServer});

/** @type {{[roomId: string]: Room}} */
let rooms = {};
/** @type {{[partyId: string]: WebSocket.WebSocket}} */
let parties = {};
/** @type {{[partyId: string]: string}} - room id of which this party is host */
let hosts = {};

/**
* @param {string} target
* @param {Types.ServerMessage} msg
*/
function send(target, msg) {
const targetWs = parties[target];
if (targetWs === undefined) {
console.warn("Trying to send to a non-existant party");
} else {
targetWs.send(encodeServerMessage(msg));
}
}

wss.on("connection", (/** @type {WebSocket.WebSocket} */ ws) => {
const roomId = makeId();
const clientId = makeId();
console.log(`New connection: clientId = ${clientId}, roomId = ${roomId}`);

rooms[roomId] = { host: clientId, watchers: [] };
parties[clientId] = ws;
hosts[clientId] = roomId;
let roomHost = clientId;

ws.send(encodeServerMessage({
senderId: clientId,
body: {
t: "identity",
roomId,
},
}));
console.log(`Sent identity to ${clientId}`);

ws.on("close", () => {
console.log(`Closing connection of ${clientId}`);
delete parties[clientId];
const oldRoom = hosts[clientId];
if (oldRoom != undefined && expect(rooms[oldRoom], "room deleted").host === clientId) {
console.log(`Delete room ${oldRoom}`);
// notify all watchers
for (const watcher of expect(rooms[oldRoom], "impossible").watchers) {
if (parties[watcher] != undefined) {
parties[watcher].send(encodeServerMessage({
senderId: clientId,
body: {
t: "leaveRoom",
},
}));
}
}
delete rooms[oldRoom];
delete hosts[clientId];
}
// TODO: I don't keep track of watchers in rooms, so I can't do notifications when watchers leave
});

ws.on("message", (/** @type {WebSocket.Message} */ message, /** @type boolean */ isBinary) => {
if (isBinary === true) {
console.warn("Binary message");
return;
}
let msg = decodeClientMessage(message);
if (msg === null) {
console.warn("Failed to decode client message");
return;
}
console.log(`Message from ${clientId}: ${msg.body.t}`);
let target = msg.targetId === null ? roomHost : msg.targetId;
if (msg.body.t === "ice") {
send(target, {
senderId: clientId,
body: msg.body,
});
} else if (msg.body.t === "sdp") {
send(target, {
senderId: clientId,
body: msg.body,
});
} else if (msg.body.t === "joinRoom") {
const room = rooms[msg.body.roomId];
if (room === undefined) {
send(clientId, {
senderId: clientId,
body: {
t: "joinRoom",
status: "404",
},
});
return;
}
// leave old room
// TODO what if room has members?
const oldRoom = hosts[clientId];
if (oldRoom != undefined && expect(rooms[oldRoom], "room deleted").host === clientId) {
delete rooms[oldRoom];
delete hosts[clientId];
}
// join new room
roomHost = room.host;
room.watchers.push(clientId);
// notify host
send(roomHost, {
senderId: clientId,
body: { t: "partyJoins" },
});
// notify watcher
send(clientId, {
senderId: clientId,
body: {
t: "joinRoom",
status: "success",
},
});
} else {
unreachable(msg.body);
}
});
});
}

main();