using TypeScript

This commit is contained in:
2021-05-25 19:33:39 +02:00
parent 968ede6f58
commit 0d7cd28ca9
8 changed files with 230 additions and 79 deletions

View File

@ -1,9 +0,0 @@
"use strict";
import * as Locutus from "./modules/locutus.js";
window.addEventListener("DOMContentLoaded", () => {
// Remove <noscript> tag
document.getElementsByTagName("noscript")[0].remove();
new Locutus.App();
});

View File

@ -1,229 +0,0 @@
"use strict";
import * as USIMP from "./usimp.js";
export class App {
account;
defaultLocation;
main;
windows;
session;
constructor() {
if (localStorage.session === undefined) {
this.defaultLocation = '/welcome';
this.account = null;
} else {
const session = JSON.parse(localStorage.session);
this.defaultLocation = '/';
this.account = session.account;
}
this.main = document.getElementsByTagName("main")[0];
this.windows = document.getElementById("windows");
window.addEventListener("hashchange", event => {
this.handleUrl(event.newURL);
});
this.handleUrl(document.URL);
}
setHash(hash) {
const url = new URL(document.URL);
url.hash = hash;
location.href = url.toString();
}
handleUrl(url) {
this.handleHash(new URL(url).hash);
}
handleHash(hash) {
if (hash[0] === '#') hash = hash.substr(1);
const defaultCase = () => {
history.replaceState(null, null, `#${this.defaultLocation}`);
this.handleHash(this.defaultLocation);
}
switch (hash) {
case "/":
if (!this.session) {
return defaultCase();
}
this.hideWindows();
this.showMain();
this.removeAllWindows();
this.setupMain();
break;
case "/welcome":
this.hideMain();
this.showWindows();
this.removeAllWindows();
this.addWelcomeWindow();
break;
case "/login":
if (this.session) {
return defaultCase();
}
this.hideMain();
this.showWindows();
this.removeAllWindows();
this.addLoginWindow();
break;
default:
console.warn(`Invalid url hash #${hash}`);
return defaultCase();
}
}
async login(accountName, domainName, password) {
let domain;
try {
domain = await USIMP.Domain.fromName(domainName);
} catch (error) {
if (error instanceof DOMException) {
throw Error("Connection timed out");
} else {
throw Error("Invalid USIMP domain");
}
}
const session = new USIMP.Session(domain);
const rtt = await session.chooseDomainServer();
console.log(rtt);
const response = await session.authenticate(accountName, password);
if (response.status === "success") {
this.session = session;
this.defaultLocation = "/";
this.setHash("/");
} else {
throw Error(response.message);
}
}
hideMain() {
this.main.style.visibility = "hidden";
}
showMain() {
this.main.style.visibility = "visible";
}
hideWindows() {
this.windows.style.visibility = "hidden";
}
showWindows() {
this.windows.style.visibility = "visible";
}
removeAllWindows() {
while (this.windows.lastChild) this.windows.removeChild(this.windows.lastChild);
}
addWelcomeWindow() {
const win = document.createElement("div");
win.classList.add("window-welcome");
win.innerHTML = `
<h1>Welcome to Locutus!</h1>
<a href="#/login" class="button">Login</a>`;
this.windows.appendChild(win);
}
addMessage(message) {
const msg = document.createElement("div");
msg.classList.add("message");
msg.innerText = message;
const chat = this.main.getElementsByClassName("chat-history")[0];
chat.appendChild(msg);
chat.scrollTop = chat.scrollHeight;
}
addLoginWindow() {
const win = document.createElement("div");
win.classList.add("window-login");
win.innerHTML = `
<h1>Login to USIMP Account</h1>
<form>
<input name="account" placeholder="Account name" type="text" required/>
<input name="domain" placeholder="Domain" type="text" pattern="([a-zA-Z0-9_-]+\\.)+[a-zA-Z]{2,}" required/>
<input name="password" placeholder="Password" type="password" required/>
<button type="submit">Login</button>
</form>`;
win.getElementsByTagName("form")[0].addEventListener("submit", async event => {
event.preventDefault();
const form = event.target;
for (const e of form) {
e.disabled = true;
e.removeAttribute("invalid");
}
try {
try {
await this.login(form.account.value, form.domain.value, form.password.value);
} finally {
for (const d of form.getElementsByTagName("div")) form.removeChild(d);
for (const e of form) e.disabled = false;
}
} catch (error) {
if (error.toString() === "Error: unable to authenticate") {
document.getElementsByName("account")[0].setAttribute("invalid", "invalid");
} else {
document.getElementsByName("domain")[0].setAttribute("invalid", "invalid");
}
const div = document.createElement("div");
div.classList.add("error");
div.innerText = error.toString();
form.appendChild(div);
}
});
this.windows.appendChild(win);
document.getElementsByName("domain")[0].addEventListener("input", (event) => {
event.target.removeAttribute("invalid");
});
document.getElementsByName("account")[0].addEventListener("input", (event) => {
event.target.removeAttribute("invalid");
});
document.getElementsByName("account")[0].focus();
}
setupMain() {
this.main.innerHTML = `
<div class="chat">
<div class="chat-history"></div>
<div class="chat-input-wrapper">
<input name="message" type="text" autocomplete="off"/>
</div>
</div>`;
const input = document.getElementsByName("message")[0];
input.addEventListener("keyup", async event => {
if (event.key === "Enter" && input.value.length > 0) {
this.addMessage(input.value);
const val = input.value;
input.value = "";
await this.session.sendEvent("60nc0XXDIYUh6QzX4p0rMpCdzDmxghZLZk8dLuQh628", {
message: val,
});
}
});
this.session.subscribe(response => {
this.addMessage(response.data.event.data.message);
});
}
}

View File

@ -1,250 +0,0 @@
"use strict";
export class Domain {
name;
id;
servers;
invalidServers;
toString() {
return `[${this.name}]`;
}
constructor(jsonData, domainServers) {
// FIXME check values
this.name = jsonData.name;
this.id = jsonData.id;
this.servers = [];
for (const domainServer of domainServers) {
this.servers.push(new DomainServer(domainServer));
}
this.invalidServers = [];
}
static async fromName(domainName) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const response = await fetch(`https://${domainName}/.well-known/usimp.json`, {
redirect: "manual",
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
throw Error("Invalid response");
}
const data = await response.json();
return new Domain(data.domain, data.domain_servers);
}
chooseDomainServer() {
if (this.servers.length === 0) throw Error("No domain servers specified");
const servers = this.servers.filter(srv => !this.invalidServers.map(srv => srv.id).includes(srv.id));
if (servers.length === 0) throw Error("No domain servers reachable");
if (servers.length === 1) return servers[0];
const priority = servers.reduce((min, srv) => Math.min(min, srv.priority), Infinity);
const domainServers = servers.filter(srv => srv.priority === priority);
if (domainServers.length === 1) return servers[0];
const totalWeight = domainServers.reduce((total, srv) => total + srv.weight, 0);
const w = Math.floor(Math.random() * totalWeight);
let accumulator = 0;
for (const srv of domainServers) {
accumulator += srv.weight;
if (w < accumulator) return srv;
}
throw Error("Domain server selection did not work correctly");
}
}
export class DomainServer {
host;
id;
priority;
weight;
protocols;
toString() {
return `[${this.host}/${this.priority}/${this.weight}]`;
}
constructor(jsonData) {
// FIXME check values
this.host = jsonData.host;
this.id = jsonData.id;
this.priority = jsonData.priority;
this.weight = jsonData.weight;
this.protocols = jsonData.protocols;
}
}
export class Account {
}
export class Member {
}
export class Room {
}
export class Event {
}
export class Session {
domain;
server;
token;
httpBaseUrl;
websocket;
numRequests;
constructor(domain) {
this.domain = domain;
this.numRequests = 0;
}
async chooseDomainServer() {
while (!this.server) {
this.server = this.domain.chooseDomainServer();
const host = this.server.host;
const protocols = this.server.protocols;
/*
if ("ws" in protocols) {
this.websocket = new WebSocket(`ws://${host}:${protocols.ws}/_usimp/websocket`, ["usimp"]);
}
*/
// TODO http -> https
if ("http" in protocols) {
this.httpBaseUrl = `http://${host}:${protocols.http}/_usimp`;
try {
return await this.ping();
} catch {
this.domain.invalidServers.push(this.server);
this.server = null;
}
} else {
console.warn(`Domain server ${this.server} does not support 'https' transport protocol`);
this.domain.invalidServers.push(this.server);
this.server = null;
}
}
}
async send(endpoint, data, timeout = 2000, forceHttp = false) {
this.numRequests++;
if (!forceHttp && this.websocket) {
this.websocket.send(JSON.stringify({
'request_num': this.numRequests,
'data': data
}));
} else {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let headers = {
'Content-Type': 'application/json',
'To-Domain': this.domain.id,
};
if (this.token) {
headers['Authorization'] = `usimp ${this.token}`;
}
const startTime = new Date();
const response = await fetch(`${this.httpBaseUrl}/${endpoint}`, {
method: "POST",
headers: headers,
body: JSON.stringify(data),
signal: controller.signal,
});
const endTime = new Date();
clearTimeout(timer);
const responseData = await response.json();
responseData.duration = endTime - startTime;
return responseData;
}
}
async ping() {
let result = {"http": null, "ws": null};
const resHttp = await this.send("ping", {}, undefined, true);
result.http = resHttp.duration;
if (this.websocket) {
const resWs = await this.send("ping", {});
result.ws = resWs.duration;
}
return result;
}
async authenticate(accountName, password) {
const response = await this.send("authenticate", {
type: "password",
name: accountName,
password: password,
});
if (response.status === "success") {
this.token = response.data.token;
}
return response;
}
async sendEvent(roomId, data) {
return this.send("send_event", {
room_id: roomId,
data: data,
});
}
subscribe(func) {
this.numRequests++;
if (this.websocket) {
// TODO
} else {
let headers = {
'Content-Type': 'application/json',
'To-Domain': this.domain.id,
};
if (this.token) {
headers['Authorization'] = `usimp ${this.token}`;
}
fetch(`${this.httpBaseUrl}/subscribe`, {
method: "POST",
headers: headers,
body: JSON.stringify({}),
}).then(response => {
return response.json();
}).then(response => {
if (response.status === "success") {
this.subscribe(func);
func(response);
} else {
setTimeout(() => {
this.subscribe(func);
}, 1000);
}
}).catch(() => {
setTimeout(() => {
this.subscribe(func);
}, 1000);
});
}
}
}