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

1
.gitignore vendored
View File

@ -1 +1,2 @@
.idea/ .idea/
www/res/scripts/*

12
Makefile Normal file
View File

@ -0,0 +1,12 @@
.DEFAULT_GOAL := build
OUT=www/res/scripts
${OUT}/locutus.js ${OUT}/usimp.js: src/locutus.ts src/usimp.ts
tsc
build: ${OUT}/locutus.js ${OUT}/usimp.js
perl -i -pE 's/(\?v=[0-9]+\.[0-9]+\.[0-9]+\+)([0-9]+)/($$1).($$2+1)/eg' www/index.html
clean:
rm -rf "${OUT}"

View File

@ -1,26 +1,26 @@
"use strict"; "use strict";
import * as USIMP from "./usimp.js"; import * as USIMP from "./usimp";
export class App { export class App {
account; account: USIMP.Account | null;
defaultLocation; defaultLocation: string;
main; main: HTMLElement;
windows; windows: HTMLElement;
session; session: USIMP.Session | null;
constructor() { constructor() {
if (localStorage.session === undefined) { this.defaultLocation = '/welcome';
this.defaultLocation = '/welcome'; this.account = null;
this.account = null; this.session = null;
} else {
const session = JSON.parse(localStorage.session);
this.defaultLocation = '/';
this.account = session.account;
}
this.main = document.getElementsByTagName("main")[0]; const main = document.getElementsByTagName("main")[0];
this.windows = document.getElementById("windows"); 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 => { window.addEventListener("hashchange", event => {
this.handleUrl(event.newURL); this.handleUrl(event.newURL);
@ -29,21 +29,21 @@ export class App {
this.handleUrl(document.URL); this.handleUrl(document.URL);
} }
setHash(hash) { setHash(hash: string) {
const url = new URL(document.URL); const url = new URL(document.URL);
url.hash = hash; url.hash = hash;
location.href = url.toString(); location.href = url.toString();
} }
handleUrl(url) { handleUrl(url: string) {
this.handleHash(new URL(url).hash); this.handleHash(new URL(url).hash);
} }
handleHash(hash) { handleHash(hash: string) {
if (hash[0] === '#') hash = hash.substr(1); if (hash[0] === '#') hash = hash.substr(1);
const defaultCase = () => { const defaultCase = () => {
history.replaceState(null, null, `#${this.defaultLocation}`); history.replaceState(null, document.title, `#${this.defaultLocation}`);
this.handleHash(this.defaultLocation); this.handleHash(this.defaultLocation);
} }
@ -78,7 +78,7 @@ export class App {
} }
} }
async login(accountName, domainName, password) { async login(accountName: string, domainName: string, password: string) {
let domain; let domain;
try { try {
domain = await USIMP.Domain.fromName(domainName); domain = await USIMP.Domain.fromName(domainName);
@ -135,13 +135,15 @@ export class App {
this.windows.appendChild(win); this.windows.appendChild(win);
} }
addMessage(message) { addMessage(message: string) {
const msg = document.createElement("div"); const msg = document.createElement("div");
msg.classList.add("message"); msg.classList.add("message");
msg.innerText = message; msg.innerText = message;
const chat = this.main.getElementsByClassName("chat-history")[0]; const chat = this.main.getElementsByClassName("chat-history")[0];
if (!chat) throw Error("Element .chat-history not found");
chat.appendChild(msg); chat.appendChild(msg);
chat.scrollTop = chat.scrollHeight; chat.scrollTop = chat.scrollHeight;
} }
@ -159,26 +161,33 @@ export class App {
<button type="submit">Login</button> <button type="submit">Login</button>
</form>`; </form>`;
win.getElementsByTagName("form")[0].addEventListener("submit", async event => { const form = win.getElementsByTagName("form")[0];
if (!form) throw Error("Element <form> not found");
form.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
const form = event.target;
for (const e of form) { for (const e of form.getElementsByTagName("button")) e.disabled = false;
for (const e of form.getElementsByTagName("input")) {
e.disabled = true; e.disabled = true;
e.removeAttribute("invalid"); e.removeAttribute("invalid");
} }
try { try {
try { try {
await this.login(form.account.value, form.domain.value, form.password.value); await this.login(form['account'].value, form['domain'].value, form['password'].value);
} finally { } finally {
for (const d of form.getElementsByTagName("div")) form.removeChild(d); for (const d of form.getElementsByTagName("div")) form.removeChild(d);
for (const e of form) e.disabled = false; for (const e of form.getElementsByTagName("input")) e.disabled = false;
for (const e of form.getElementsByTagName("button")) e.disabled = false;
} }
} catch (error) { } catch (error) {
if (error.toString() === "Error: unable to authenticate") { if (error.toString() === "Error: unable to authenticate") {
document.getElementsByName("account")[0].setAttribute("invalid", "invalid"); const account = document.getElementsByName("account")[0];
if (account) account.setAttribute("invalid", "invalid");
} else { } else {
document.getElementsByName("domain")[0].setAttribute("invalid", "invalid"); const domain = document.getElementsByName("domain")[0];
if (domain) domain.setAttribute("invalid", "invalid");
} }
const div = document.createElement("div"); const div = document.createElement("div");
@ -190,18 +199,26 @@ export class App {
this.windows.appendChild(win); this.windows.appendChild(win);
document.getElementsByName("domain")[0].addEventListener("input", (event) => { const domain = document.getElementsByName("domain")[0];
event.target.removeAttribute("invalid"); if (!domain) throw Error("Element name=domain not found");
domain.addEventListener("input", (event) => {
domain.removeAttribute("invalid");
}); });
document.getElementsByName("account")[0].addEventListener("input", (event) => { const account = document.getElementsByName("account")[0];
event.target.removeAttribute("invalid"); if (!account) throw Error("Element name=account not found");
account.addEventListener("input", (event) => {
account.removeAttribute("invalid");
}); });
document.getElementsByName("account")[0].focus(); account.focus();
} }
setupMain() { setupMain() {
if (!this.session) throw Error("Invalid state");
this.main.innerHTML = ` this.main.innerHTML = `
<div class="chat"> <div class="chat">
<div class="chat-history"></div> <div class="chat-history"></div>
@ -210,9 +227,13 @@ export class App {
</div> </div>
</div>`; </div>`;
const input = document.getElementsByName("message")[0]; const input = document.getElementsByTagName("input")[0];
if (!input) throw Error("Element <input> not found");
input.addEventListener("keyup", async event => { input.addEventListener("keyup", async event => {
if (event.key === "Enter" && input.value.length > 0) { if (event.key === "Enter" && input.value.length > 0) {
if (!this.session) throw Error("No session found");
this.addMessage(input.value); this.addMessage(input.value);
const val = input.value; const val = input.value;
input.value = ""; input.value = "";

11
src/main.ts Normal file
View 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();
});

View File

@ -1,16 +1,48 @@
"use strict"; "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 { export class Domain {
name; name: string;
id; id: string;
servers; servers: DomainServer[];
invalidServers; invalidServers: DomainServer[];
toString() { toString() {
return `[${this.name}]`; return `[${this.name}]`;
} }
constructor(jsonData, domainServers) { constructor(jsonData: DomainJson, domainServers: DomainServerJson[]) {
// FIXME check values // FIXME check values
this.name = jsonData.name; this.name = jsonData.name;
this.id = jsonData.id; this.id = jsonData.id;
@ -21,7 +53,7 @@ export class Domain {
this.invalidServers = []; this.invalidServers = [];
} }
static async fromName(domainName) { static async fromName(domainName: string): Promise<Domain> {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000); const timer = setTimeout(() => controller.abort(), 2000);
@ -35,20 +67,20 @@ export class Domain {
throw Error("Invalid response"); throw Error("Invalid response");
} }
const data = await response.json(); const data: WellKnownJson = await response.json();
return new Domain(data.domain, data.domain_servers); return new Domain(data.domain, data.domain_servers);
} }
chooseDomainServer() { chooseDomainServer(): DomainServer {
if (this.servers.length === 0) throw Error("No domain servers specified"); 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)); 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 === 0) throw Error("No domain servers reachable");
if (servers.length === 1) return servers[0]; if (servers.length === 1 && servers[0]) return servers[0];
const priority = servers.reduce((min, srv) => Math.min(min, srv.priority), Infinity); const priority = servers.reduce((min, srv) => Math.min(min, srv.priority), Infinity);
const domainServers = servers.filter(srv => srv.priority === priority); const domainServers = servers.filter(srv => srv.priority === priority);
if (domainServers.length === 1) return servers[0]; if (domainServers.length === 1 && servers[0]) return servers[0];
const totalWeight = domainServers.reduce((total, srv) => total + srv.weight, 0); const totalWeight = domainServers.reduce((total, srv) => total + srv.weight, 0);
const w = Math.floor(Math.random() * totalWeight); const w = Math.floor(Math.random() * totalWeight);
@ -64,18 +96,23 @@ export class Domain {
} }
export class DomainServer { export class DomainServer {
host; host: string;
id; id: string;
priority; priority: number;
weight; weight: number;
protocols; protocols: {
https: number | undefined,
wss: number | undefined,
http: number | undefined,
ws: number | undefined,
udp: number | undefined
};
toString() { toString() {
return `[${this.host}/${this.priority}/${this.weight}]`; return `[${this.host}/${this.priority}/${this.weight}]`;
} }
constructor(jsonData) { constructor(jsonData: DomainServerJson) {
// FIXME check values
this.host = jsonData.host; this.host = jsonData.host;
this.id = jsonData.id; this.id = jsonData.id;
this.priority = jsonData.priority; this.priority = jsonData.priority;
@ -101,20 +138,24 @@ export class Event {
} }
export class Session { export class Session {
domain; domain: Domain;
server; server: DomainServer | null;
token; token: string | null;
httpBaseUrl; httpBaseUrl: string | null;
websocket; websocket: WebSocket | null;
numRequests; numRequests: number;
constructor(domain) { constructor(domain: Domain) {
this.domain = domain; this.domain = domain;
this.numRequests = 0; this.numRequests = 0;
this.server = null;
this.token = null;
this.httpBaseUrl = null;
this.websocket = null;
} }
async chooseDomainServer() { async chooseDomainServer(): Promise<{ http: number | null, ws: number | null } | undefined> {
while (!this.server) { while (!this.server) {
this.server = this.domain.chooseDomainServer(); this.server = this.domain.chooseDomainServer();
@ -128,7 +169,7 @@ export class Session {
*/ */
// TODO http -> https // TODO http -> https
if ("http" in protocols) { if (protocols) {
this.httpBaseUrl = `http://${host}:${protocols.http}/_usimp`; this.httpBaseUrl = `http://${host}:${protocols.http}/_usimp`;
try { try {
return await this.ping(); return await this.ping();
@ -142,9 +183,10 @@ export class Session {
this.server = null; this.server = null;
} }
} }
return undefined;
} }
async send(endpoint, data, timeout = 2000, forceHttp = false) { async send(endpoint: string, data: object, timeout: number = 2000, forceHttp: boolean = false) {
this.numRequests++; this.numRequests++;
if (!forceHttp && this.websocket) { if (!forceHttp && this.websocket) {
@ -156,7 +198,7 @@ export class Session {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout); const timer = setTimeout(() => controller.abort(), timeout);
let headers = { let headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'To-Domain': this.domain.id, 'To-Domain': this.domain.id,
}; };
@ -175,7 +217,7 @@ export class Session {
clearTimeout(timer); clearTimeout(timer);
const responseData = await response.json(); const responseData = await response.json();
responseData.duration = endTime - startTime; responseData.duration = endTime.getUTCMilliseconds() - startTime.getUTCMilliseconds();
return responseData; return responseData;
} }
} }
@ -193,7 +235,7 @@ export class Session {
return result; return result;
} }
async authenticate(accountName, password) { async authenticate(accountName: string, password: string) {
const response = await this.send("authenticate", { const response = await this.send("authenticate", {
type: "password", type: "password",
name: accountName, name: accountName,
@ -205,19 +247,19 @@ export class Session {
return response; return response;
} }
async sendEvent(roomId, data) { async sendEvent(roomId: string, data: object) {
return this.send("send_event", { return this.send("send_event", {
room_id: roomId, room_id: roomId,
data: data, data: data,
}); });
} }
subscribe(func) { subscribe(func: (a: EventJson) => void) {
this.numRequests++; this.numRequests++;
if (this.websocket) { if (this.websocket) {
// TODO // TODO
} else { } else {
let headers = { let headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'To-Domain': this.domain.id, 'To-Domain': this.domain.id,
}; };

71
tsconfig.json Normal file
View File

@ -0,0 +1,71 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "www/res/scripts", /* Redirect output structure to the directory. */
"rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
"noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}

View File

@ -19,8 +19,10 @@ Distributed, end-to-end encrypted instant messaging."/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" sizes="64x64" href="/favicon.ico" type="image/x-icon"/> <link rel="icon" sizes="64x64" href="/favicon.ico" type="image/x-icon"/>
<link rel="stylesheet" href="/res/styles/styles.css?v=0.0.0-0" type="text/css"/> <link rel="stylesheet" href="/res/styles/styles.css?v=0.0.0+10" type="text/css"/>
<script src="/res/js/main.js?v=0.0.0-0" type="module"></script> <script type="module" src="/res/scripts/usimp.js?v=0.0.0+10"></script>
<script type="module" src="/res/scripts/locutus.js?v=0.0.0+10"></script>
<script type="module" src="/res/scripts/main.js?v=0.0.0+10"></script>
<meta http-equiv="Content-Security-Policy" content=" <meta http-equiv="Content-Security-Policy" content="
default-src 'none'; default-src 'none';
style-src 'self'; style-src 'self';

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();
});