using TypeScript
This commit is contained in:
250
src/locutus.ts
Normal file
250
src/locutus.ts
Normal file
@ -0,0 +1,250 @@
|
||||
"use strict";
|
||||
|
||||
import * as USIMP from "./usimp";
|
||||
|
||||
export class App {
|
||||
account: USIMP.Account | null;
|
||||
defaultLocation: string;
|
||||
main: HTMLElement;
|
||||
windows: HTMLElement;
|
||||
session: USIMP.Session | null;
|
||||
|
||||
constructor() {
|
||||
this.defaultLocation = '/welcome';
|
||||
this.account = null;
|
||||
this.session = null;
|
||||
|
||||
const main = document.getElementsByTagName("main")[0];
|
||||
if (!main) throw Error("Element <main> not found");
|
||||
this.main = main;
|
||||
|
||||
const windows = document.getElementById("windows");
|
||||
if (!windows) throw Error("Element #windows not found");
|
||||
this.windows = windows;
|
||||
|
||||
window.addEventListener("hashchange", event => {
|
||||
this.handleUrl(event.newURL);
|
||||
});
|
||||
|
||||
this.handleUrl(document.URL);
|
||||
}
|
||||
|
||||
setHash(hash: string) {
|
||||
const url = new URL(document.URL);
|
||||
url.hash = hash;
|
||||
location.href = url.toString();
|
||||
}
|
||||
|
||||
handleUrl(url: string) {
|
||||
this.handleHash(new URL(url).hash);
|
||||
}
|
||||
|
||||
handleHash(hash: string) {
|
||||
if (hash[0] === '#') hash = hash.substr(1);
|
||||
|
||||
const defaultCase = () => {
|
||||
history.replaceState(null, document.title, `#${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: string, domainName: string, password: string) {
|
||||
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: string) {
|
||||
const msg = document.createElement("div");
|
||||
msg.classList.add("message");
|
||||
|
||||
msg.innerText = message;
|
||||
|
||||
const chat = this.main.getElementsByClassName("chat-history")[0];
|
||||
if (!chat) throw Error("Element .chat-history not found");
|
||||
|
||||
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>`;
|
||||
|
||||
const form = win.getElementsByTagName("form")[0];
|
||||
if (!form) throw Error("Element <form> not found");
|
||||
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
|
||||
for (const e of form.getElementsByTagName("button")) e.disabled = false;
|
||||
for (const e of form.getElementsByTagName("input")) {
|
||||
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.getElementsByTagName("input")) e.disabled = false;
|
||||
for (const e of form.getElementsByTagName("button")) e.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.toString() === "Error: unable to authenticate") {
|
||||
const account = document.getElementsByName("account")[0];
|
||||
if (account) account.setAttribute("invalid", "invalid");
|
||||
} else {
|
||||
const domain = document.getElementsByName("domain")[0];
|
||||
if (domain) domain.setAttribute("invalid", "invalid");
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("error");
|
||||
div.innerText = error.toString();
|
||||
form.appendChild(div);
|
||||
}
|
||||
});
|
||||
|
||||
this.windows.appendChild(win);
|
||||
|
||||
const domain = document.getElementsByName("domain")[0];
|
||||
if (!domain) throw Error("Element name=domain not found");
|
||||
|
||||
domain.addEventListener("input", (event) => {
|
||||
domain.removeAttribute("invalid");
|
||||
});
|
||||
|
||||
const account = document.getElementsByName("account")[0];
|
||||
if (!account) throw Error("Element name=account not found");
|
||||
|
||||
account.addEventListener("input", (event) => {
|
||||
account.removeAttribute("invalid");
|
||||
});
|
||||
|
||||
account.focus();
|
||||
}
|
||||
|
||||
setupMain() {
|
||||
if (!this.session) throw Error("Invalid state");
|
||||
|
||||
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.getElementsByTagName("input")[0];
|
||||
if (!input) throw Error("Element <input> not found");
|
||||
|
||||
input.addEventListener("keyup", async event => {
|
||||
if (event.key === "Enter" && input.value.length > 0) {
|
||||
if (!this.session) throw Error("No session found");
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
11
src/main.ts
Normal file
11
src/main.ts
Normal file
@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
import * as Locutus from "./locutus";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// Remove <noscript> tags
|
||||
for (const noscript of document.getElementsByTagName("noscript")) {
|
||||
noscript.remove();
|
||||
}
|
||||
new Locutus.App();
|
||||
});
|
292
src/usimp.ts
Normal file
292
src/usimp.ts
Normal file
@ -0,0 +1,292 @@
|
||||
"use strict";
|
||||
|
||||
interface DomainJson {
|
||||
name: string,
|
||||
id: string,
|
||||
}
|
||||
|
||||
interface DomainServerJson {
|
||||
host: string,
|
||||
id: string,
|
||||
priority: number,
|
||||
weight: number,
|
||||
protocols: {
|
||||
https: number | undefined,
|
||||
wss: number | undefined,
|
||||
http: number | undefined,
|
||||
ws: number | undefined,
|
||||
udp: number | undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface EventJson {
|
||||
data: {
|
||||
event: {
|
||||
data: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface WellKnownJson {
|
||||
domain: DomainJson,
|
||||
domain_servers: DomainServerJson[]
|
||||
}
|
||||
|
||||
export class Domain {
|
||||
name: string;
|
||||
id: string;
|
||||
servers: DomainServer[];
|
||||
invalidServers: DomainServer[];
|
||||
|
||||
toString() {
|
||||
return `[${this.name}]`;
|
||||
}
|
||||
|
||||
constructor(jsonData: DomainJson, domainServers: DomainServerJson[]) {
|
||||
// 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: string): Promise<Domain> {
|
||||
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: WellKnownJson = await response.json();
|
||||
return new Domain(data.domain, data.domain_servers);
|
||||
}
|
||||
|
||||
chooseDomainServer(): DomainServer {
|
||||
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 && servers[0]) 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 && servers[0]) 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: string;
|
||||
id: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
protocols: {
|
||||
https: number | undefined,
|
||||
wss: number | undefined,
|
||||
http: number | undefined,
|
||||
ws: number | undefined,
|
||||
udp: number | undefined
|
||||
};
|
||||
|
||||
toString() {
|
||||
return `[${this.host}/${this.priority}/${this.weight}]`;
|
||||
}
|
||||
|
||||
constructor(jsonData: DomainServerJson) {
|
||||
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: Domain;
|
||||
server: DomainServer | null;
|
||||
token: string | null;
|
||||
|
||||
httpBaseUrl: string | null;
|
||||
websocket: WebSocket | null;
|
||||
numRequests: number;
|
||||
|
||||
constructor(domain: Domain) {
|
||||
this.domain = domain;
|
||||
this.numRequests = 0;
|
||||
this.server = null;
|
||||
this.token = null;
|
||||
this.httpBaseUrl = null;
|
||||
this.websocket = null;
|
||||
}
|
||||
|
||||
async chooseDomainServer(): Promise<{ http: number | null, ws: number | null } | undefined> {
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async send(endpoint: string, data: object, timeout: number = 2000, forceHttp: boolean = 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: Record<string, string> = {
|
||||
'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.getUTCMilliseconds() - startTime.getUTCMilliseconds();
|
||||
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: string, password: string) {
|
||||
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: string, data: object) {
|
||||
return this.send("send_event", {
|
||||
room_id: roomId,
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(func: (a: EventJson) => void) {
|
||||
this.numRequests++;
|
||||
if (this.websocket) {
|
||||
// TODO
|
||||
} else {
|
||||
let headers: Record<string, string> = {
|
||||
'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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user