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/
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";
import * as USIMP from "./usimp.js";
import * as USIMP from "./usimp";
export class App {
account;
defaultLocation;
main;
windows;
session;
account: USIMP.Account | null;
defaultLocation: string;
main: HTMLElement;
windows: HTMLElement;
session: USIMP.Session | null;
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.defaultLocation = '/welcome';
this.account = null;
this.session = null;
this.main = document.getElementsByTagName("main")[0];
this.windows = document.getElementById("windows");
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);
@ -29,21 +29,21 @@ export class App {
this.handleUrl(document.URL);
}
setHash(hash) {
setHash(hash: string) {
const url = new URL(document.URL);
url.hash = hash;
location.href = url.toString();
}
handleUrl(url) {
handleUrl(url: string) {
this.handleHash(new URL(url).hash);
}
handleHash(hash) {
handleHash(hash: string) {
if (hash[0] === '#') hash = hash.substr(1);
const defaultCase = () => {
history.replaceState(null, null, `#${this.defaultLocation}`);
history.replaceState(null, document.title, `#${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;
try {
domain = await USIMP.Domain.fromName(domainName);
@ -135,13 +135,15 @@ export class App {
this.windows.appendChild(win);
}
addMessage(message) {
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;
}
@ -159,26 +161,33 @@ export class App {
<button type="submit">Login</button>
</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();
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.removeAttribute("invalid");
}
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 {
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) {
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 {
document.getElementsByName("domain")[0].setAttribute("invalid", "invalid");
const domain = document.getElementsByName("domain")[0];
if (domain) domain.setAttribute("invalid", "invalid");
}
const div = document.createElement("div");
@ -190,18 +199,26 @@ export class App {
this.windows.appendChild(win);
document.getElementsByName("domain")[0].addEventListener("input", (event) => {
event.target.removeAttribute("invalid");
const domain = document.getElementsByName("domain")[0];
if (!domain) throw Error("Element name=domain not found");
domain.addEventListener("input", (event) => {
domain.removeAttribute("invalid");
});
document.getElementsByName("account")[0].addEventListener("input", (event) => {
event.target.removeAttribute("invalid");
const account = document.getElementsByName("account")[0];
if (!account) throw Error("Element name=account not found");
account.addEventListener("input", (event) => {
account.removeAttribute("invalid");
});
document.getElementsByName("account")[0].focus();
account.focus();
}
setupMain() {
if (!this.session) throw Error("Invalid state");
this.main.innerHTML = `
<div class="chat">
<div class="chat-history"></div>
@ -210,9 +227,13 @@ export class App {
</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 => {
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 = "";

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";
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;
id;
servers;
invalidServers;
name: string;
id: string;
servers: DomainServer[];
invalidServers: DomainServer[];
toString() {
return `[${this.name}]`;
}
constructor(jsonData, domainServers) {
constructor(jsonData: DomainJson, domainServers: DomainServerJson[]) {
// FIXME check values
this.name = jsonData.name;
this.id = jsonData.id;
@ -21,7 +53,7 @@ export class Domain {
this.invalidServers = [];
}
static async fromName(domainName) {
static async fromName(domainName: string): Promise<Domain> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
@ -35,20 +67,20 @@ export class Domain {
throw Error("Invalid response");
}
const data = await response.json();
const data: WellKnownJson = await response.json();
return new Domain(data.domain, data.domain_servers);
}
chooseDomainServer() {
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) 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 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 w = Math.floor(Math.random() * totalWeight);
@ -64,18 +96,23 @@ export class Domain {
}
export class DomainServer {
host;
id;
priority;
weight;
protocols;
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) {
// FIXME check values
constructor(jsonData: DomainServerJson) {
this.host = jsonData.host;
this.id = jsonData.id;
this.priority = jsonData.priority;
@ -101,20 +138,24 @@ export class Event {
}
export class Session {
domain;
server;
token;
domain: Domain;
server: DomainServer | null;
token: string | null;
httpBaseUrl;
websocket;
numRequests;
httpBaseUrl: string | null;
websocket: WebSocket | null;
numRequests: number;
constructor(domain) {
constructor(domain: Domain) {
this.domain = domain;
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) {
this.server = this.domain.chooseDomainServer();
@ -128,7 +169,7 @@ export class Session {
*/
// TODO http -> https
if ("http" in protocols) {
if (protocols) {
this.httpBaseUrl = `http://${host}:${protocols.http}/_usimp`;
try {
return await this.ping();
@ -142,9 +183,10 @@ export class Session {
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++;
if (!forceHttp && this.websocket) {
@ -156,7 +198,7 @@ export class Session {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let headers = {
let headers: Record<string, string> = {
'Content-Type': 'application/json',
'To-Domain': this.domain.id,
};
@ -175,7 +217,7 @@ export class Session {
clearTimeout(timer);
const responseData = await response.json();
responseData.duration = endTime - startTime;
responseData.duration = endTime.getUTCMilliseconds() - startTime.getUTCMilliseconds();
return responseData;
}
}
@ -193,7 +235,7 @@ export class Session {
return result;
}
async authenticate(accountName, password) {
async authenticate(accountName: string, password: string) {
const response = await this.send("authenticate", {
type: "password",
name: accountName,
@ -205,19 +247,19 @@ export class Session {
return response;
}
async sendEvent(roomId, data) {
async sendEvent(roomId: string, data: object) {
return this.send("send_event", {
room_id: roomId,
data: data,
});
}
subscribe(func) {
subscribe(func: (a: EventJson) => void) {
this.numRequests++;
if (this.websocket) {
// TODO
} else {
let headers = {
let headers: Record<string, string> = {
'Content-Type': 'application/json',
'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="viewport" content="width=device-width, initial-scale=1.0"/>
<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"/>
<script src="/res/js/main.js?v=0.0.0-0" type="module"></script>
<link rel="stylesheet" href="/res/styles/styles.css?v=0.0.0+10" type="text/css"/>
<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="
default-src 'none';
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();
});