Compare commits

..

4 Commits

3 changed files with 263 additions and 72 deletions

View File

@@ -210,8 +210,12 @@ export class App {
<input name="account" placeholder="Account name" type="text" required/> <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="domain" placeholder="Domain" type="text" pattern="([a-zA-Z0-9_-]+\\.)+[a-zA-Z]{2,}" required/>
<input name="password" placeholder="Password" type="password" required/> <input name="password" placeholder="Password" type="password" required/>
<label><input name="saveAccountName" type="checkbox"/> Save Account Name</label> <fieldset>
<label><input name="keepSession" type="checkbox"/> Keep me signed in</label> <label><input name="saveAccountName" type="checkbox"/> Save Account Name</label>
</fieldset>
<fieldset>
<label><input name="keepSession" type="checkbox"/> Keep me signed in</label>
</fieldset>
<button type="submit">Login</button> <button type="submit">Login</button>
</form>`; </form>`;

View File

@@ -1,46 +1,137 @@
"use strict"; "use strict";
interface DomainJson { const DNS_RE = /^([a-z0-9_-]+\.)+[a-z]{2,}$/i;
const UUID_RE = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
const WEBSOCKET_KEEP_ALIVE = 5 * 60; // [sec]
type DomainJson = {
name: string, name: string,
id: string, id: string,
} }
interface DomainServerJson { function isDomainJson(json: any): json is DomainJson {
return (
json !== null && typeof json === 'object' && json.constructor === Object &&
typeof json.name === 'string' && typeof json.id === 'string' &&
DNS_RE.test(json.name) && UUID_RE.test(json.id)
);
}
type DomainServerJson = {
host: string, host: string,
id: string, id: string,
priority: number, priority: number,
weight: number, weight: number,
protocols: { protocols: {
https: number | undefined, https?: number | null,
wss: number | undefined, wss?: number | null,
http: number | undefined, http?: number | null,
ws: number | undefined, ws?: number | null,
udp: number | undefined udp?: number | null
} }
} }
interface OutputEnvelopeJson { function isDomainServerJson(json: any): json is DomainServerJson {
if (
json === null || typeof json !== 'object' || json.constructor !== Object ||
typeof json.host !== 'string' || typeof json.id !== 'string' || typeof json.priority !== 'number' ||
typeof json.weight !== 'number' ||
json.protocols === null || typeof json.protocols !== 'object' || json.protocols.constructor !== Object ||
!DNS_RE.test(json.host) || !UUID_RE.test(json.id) || json.priority < 0 || json.weight < 0
) return false;
return (
(json.protocols.https === undefined || json.protocols.https === null || (
typeof json.protocols.https === 'number' && json.protocols.https > 0 && json.protocols.https <= 65535
)) && (json.protocols.wss === undefined || json.protocols.wss === null || (
typeof json.protocols.wss === 'number' && json.protocols.wss > 0 && json.protocols.wss <= 65535
)) && (json.protocols.http === undefined || json.protocols.http === null || (
typeof json.protocols.http === 'number' && json.protocols.http > 0 && json.protocols.http <= 65535
)) && (json.protocols.ws === undefined || json.protocols.ws === null || (
typeof json.protocols.ws === 'number' && json.protocols.ws > 0 && json.protocols.ws <= 65535
)) && (json.protocols.udp === undefined || json.protocols.udp === null || (
typeof json.protocols.udp === 'number' && json.protocols.udp > 0 && json.protocols.udp <= 65535
))
);
}
type WellKnownJson = {
domain: DomainJson,
domain_servers: DomainServerJson[],
}
function isWellKnownJson(json: any): json is WellKnownJson {
if (
json === null || typeof json !== 'object' || json.constructor !== Object ||
!json.hasOwnProperty('domain') || !json.hasOwnProperty('domain_servers') ||
json.domain_servers === null || typeof json.domain_servers !== 'object' ||
json.domain_servers.constructor !== Array
) return false;
return isDomainJson(json.domain) && json.domain_servers.every((item: unknown) => isDomainServerJson(item));
}
type OutputEnvelopeJson<T> = {
status: string, status: string,
requestNr: number, request_nr: number,
data: any, data: T,
action: string | null | undefined, action?: string | null,
error: { error?: {
code: string, code: string,
message: string | null, message: string | null,
description: string | null, description: string | null,
} | null, } | null,
} }
interface EventJson { function isOutputEnvelopeJson<T>(json: any): json is OutputEnvelopeJson<T> {
data: { if (
message: string, json === null || typeof json !== 'object' || json.constructor !== Object ||
typeof json.status !== 'string' || typeof json.request_nr !== 'number' || json.request_nr < 0 ||
(typeof json.action !== 'string' && json.action !== null && json.action === undefined) ||
json.data === null || typeof json.data !== 'object' || json.data.constructor !== Object
) return false;
if (json.error !== null && json.error !== undefined) {
if (
typeof json.error !== 'object' || json.error.constructor !== Object ||
typeof json.error.code !== 'string' ||
(typeof json.error.message !== 'string' && json.error.message !== null) ||
(typeof json.error.description !== 'string' && json.error.description !== null)
) return false;
} }
id: string,
return true;
} }
interface WellKnownJson { type EventPushEnvelopeJson = {
domain: DomainJson, events: EventJson[],
domain_servers: DomainServerJson[] }
function isEventPushEnvelopeJson(json: any): json is EventPushEnvelopeJson {
if (
json === null || typeof json !== 'object' || json.constructor !== Object ||
json.events === null || typeof json.events !== 'object' || json.events.constructor !== Array
) return false;
return json.events.every((item: unknown) => isEventJson(item));
}
type EventJson = {
id: string,
data: {
message: string,
},
}
function isEventJson(json: any): json is EventJson {
return !(
json === null || typeof json !== 'object' || json.constructor !== Object ||
typeof json.id !== 'string' || !UUID_RE.test(json.id) ||
json.data === null || typeof json.data !== 'object' || json.data.constructor !== Object ||
typeof json.data.message !== 'string'
);
} }
export class Domain { export class Domain {
@@ -53,10 +144,9 @@ export class Domain {
return `[${this.name}]`; return `[${this.name}]`;
} }
constructor(jsonData: DomainJson, domainServers: DomainServerJson[]) { constructor(json: DomainJson, domainServers: DomainServerJson[]) {
// FIXME check values this.name = json.name;
this.name = jsonData.name; this.id = json.id;
this.id = jsonData.id;
this.servers = []; this.servers = [];
for (const domainServer of domainServers) { for (const domainServer of domainServers) {
this.servers.push(new DomainServer(domainServer)); this.servers.push(new DomainServer(domainServer));
@@ -78,8 +168,12 @@ export class Domain {
throw new Error("Invalid response"); throw new Error("Invalid response");
} }
const data: WellKnownJson = await response.json(); const data = await response.json();
return new Domain(data.domain, data.domain_servers); if (isWellKnownJson(data)) {
return new Domain(data.domain, data.domain_servers);
} else {
throw Error("Invalid contents in usimp.json");
}
} }
chooseDomainServer(): DomainServer { chooseDomainServer(): DomainServer {
@@ -112,23 +206,29 @@ export class DomainServer {
priority: number; priority: number;
weight: number; weight: number;
protocols: { protocols: {
https: number | undefined, https: number | null,
wss: number | undefined, wss: number | null,
http: number | undefined, http: number | null,
ws: number | undefined, ws: number | null,
udp: number | undefined udp: number | null
}; };
toString() { toString() {
return `[${this.host}/${this.priority}/${this.weight}]`; return `[${this.host}/${this.priority}/${this.weight}]`;
} }
constructor(jsonData: DomainServerJson) { constructor(json: DomainServerJson) {
this.host = jsonData.host; this.host = json.host;
this.id = jsonData.id; this.id = json.id;
this.priority = jsonData.priority; this.priority = json.priority;
this.weight = jsonData.weight; this.weight = json.weight;
this.protocols = jsonData.protocols; this.protocols = {
https: json.protocols.https || null,
wss: json.protocols.wss || null,
http: json.protocols.http || null,
ws: json.protocols.ws || null,
udp: json.protocols.udp || null,
};
} }
} }
@@ -145,9 +245,51 @@ export class Room {
} }
export class Event { export class Event {
id: string;
data: {
message: string;
};
constructor(json: EventJson) {
this.data = {
message: json.data.message,
};
this.id = json.id;
}
} }
type EventHandler = (event: Event) => void;
export class OutputEnvelope<T> {
status: string;
requestNr: number;
data: T;
action: string | null;
error: {
code: string,
message: string | null,
description: string | null,
} | null;
constructor(json: OutputEnvelopeJson<T>) {
this.status = json.status;
this.requestNr = json.request_nr;
this.data = json.data;
this.action = json.action || null;
if (json.error) {
this.error = {
code: json.error.code,
message: json.error.message,
description: json.error.description,
};
} else {
this.error = null;
}
}
}
type OutputEnvelopeHandler<T> = (envelope: OutputEnvelope<T>) => void;
export class Session { export class Session {
domain: Domain; domain: Domain;
server: DomainServer | null; server: DomainServer | null;
@@ -155,37 +297,65 @@ export class Session {
httpBaseUrl: string | null; httpBaseUrl: string | null;
websocket: WebSocket | null; websocket: WebSocket | null;
websocketPingInterval: number | null;
numRequests: number; numRequests: number;
requestNumDiscriminator: number;
subscriptions: { subscriptions: {
callback: (a: EventJson) => void, callback: EventHandler,
requestNr: number | undefined, requestNr: number | undefined,
abortController: AbortController | undefined, abortController: AbortController | undefined,
}[]; }[];
subscriptionEndpoints: { subscriptionEndpoints: {
callback: (a: EventJson) => void, callback: EventHandler,
}[]; }[];
constructor(domain: Domain) { constructor(domain: Domain) {
this.domain = domain; this.domain = domain;
this.numRequests = 0; this.numRequests = 0;
this.requestNumDiscriminator = Session.genRequestNumDiscriminator();
this.server = null; this.server = null;
this.token = null; this.token = null;
this.httpBaseUrl = null; this.httpBaseUrl = null;
this.websocket = null; this.websocket = null;
this.websocketPingInterval = null;
this.subscriptions = []; this.subscriptions = [];
this.subscriptionEndpoints = []; this.subscriptionEndpoints = [];
} }
private static genRequestNumDiscriminator(): number {
// JS uses 64-bit double precision floating point numbers.
// These can accurately represent integers up to 53 bit.
// In JS bitwise operators only operate on 32-bit integers.
// Here a 48-bit integer number is generated.
return Math.floor(Math.random() * 0x1000000) * 0x1000000;
}
async close(keepEndpoints: boolean = false) { async close(keepEndpoints: boolean = false) {
await this.unsubscribeAll(keepEndpoints); await this.unsubscribeAll(keepEndpoints);
if (this.websocket && (this.websocket.readyState !== WebSocket.CLOSING && this.websocket.readyState !== WebSocket.CLOSED)) { if (this.websocket && (this.websocket.readyState !== WebSocket.CLOSING && this.websocket.readyState !== WebSocket.CLOSED)) {
this.websocket.close(); this.closeWebsocket()
this.subscriptions = []; this.subscriptions = [];
this.websocket = null;
this.server = null; this.server = null;
} }
} }
private setWebsocketNull() {
if (this.websocketPingInterval) clearInterval(this.websocketPingInterval);
this.websocket = null;
this.websocketPingInterval = null;
}
closeWebsocket() {
if (this.websocket) {
this.websocket.close();
this.setWebsocketNull();
}
}
hasWebsocket(): boolean {
return this.websocket !== null;
}
async sleep() { async sleep() {
await this.close(true); await this.close(true);
} }
@@ -209,8 +379,11 @@ export class Session {
this.websocket = new WebSocket(`wss://${host}:${protocols.wss}/_usimp/websocket`, ["usimp"]); this.websocket = new WebSocket(`wss://${host}:${protocols.wss}/_usimp/websocket`, ["usimp"]);
this.websocket.addEventListener("error", (error) => { this.websocket.addEventListener("error", (error) => {
console.error(error); console.error(error);
this.websocket = null; this.setWebsocketNull();
}); });
this.websocketPingInterval = setInterval(() => {
if (this.hasWebsocket()) this.send("ping", {})
}, WEBSOCKET_KEEP_ALIVE * 1000);
} }
if ("https" in protocols) { if ("https" in protocols) {
@@ -293,17 +466,17 @@ export class Session {
async send( async send(
endpoint: string, endpoint: string,
data: any, data: Object,
timeout: number = 2000, timeout: number = 2000,
forceHttp: boolean = false, forceHttp: boolean = false,
abortController: AbortController | undefined = undefined, abortController: AbortController | undefined = undefined,
cb: ((a: OutputEnvelopeJson) => void) | undefined = undefined, cb: OutputEnvelopeHandler<unknown> | undefined = undefined,
) { ) {
this.numRequests++; this.numRequests++;
while (true) { while (true) {
try { try {
if (!forceHttp && this.websocket !== null) { if (!forceHttp && this.websocket !== null) {
const req_nr = this.numRequests; const req_nr = this.numRequests + this.requestNumDiscriminator;
const startTime = performance.now(); const startTime = performance.now();
await this.waitForWebSocket(timeout); await this.waitForWebSocket(timeout);
@@ -314,8 +487,12 @@ export class Session {
} else { } else {
this.websocket.addEventListener("message", (msg) => { this.websocket.addEventListener("message", (msg) => {
const data = JSON.parse(msg.data); const data = JSON.parse(msg.data);
if (data['request_nr'] === req_nr) { if (data !== null && data.constructor === Object && data['request_nr'] === req_nr) {
cb(data); if (isOutputEnvelopeJson<unknown>(data)) {
cb(new OutputEnvelope<unknown>(data));
} else {
throw new Error("Invalid server response");
}
} }
}); });
} }
@@ -366,10 +543,8 @@ export class Session {
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (this.websocket !== null) { if (this.hasWebsocket()) this.closeWebsocket();
this.websocket.close();
this.websocket = null;
}
this.httpBaseUrl = null; this.httpBaseUrl = null;
if (this.server !== null) { if (this.server !== null) {
this.domain.invalidServers.push(this.server); this.domain.invalidServers.push(this.server);
@@ -385,7 +560,7 @@ export class Session {
const resHttp = await this.send("ping", {}, undefined, true); const resHttp = await this.send("ping", {}, undefined, true);
result.http = resHttp.duration; result.http = resHttp.duration;
if (this.websocket) { if (this.hasWebsocket()) {
const resWs = await this.send("ping", {}); const resWs = await this.send("ping", {});
result.ws = resWs.duration; result.ws = resWs.duration;
} }
@@ -405,7 +580,7 @@ export class Session {
return response; return response;
} }
async newEvent(roomId: string, data: any, id: string | undefined = undefined) { async newEvent(roomId: string, data: Object, id: string | undefined = undefined) {
return this.send("new_event", { return this.send("new_event", {
room_id: roomId, room_id: roomId,
events: [{ events: [{
@@ -415,18 +590,21 @@ export class Session {
}); });
} }
async subscribe(cb: (a: EventJson) => void) { async subscribe(cb: EventHandler) {
this.subscriptionEndpoints.push({callback: cb}); this.subscriptionEndpoints.push({callback: cb});
await this._subscribe(cb); await this._subscribe(cb);
} }
private async _subscribe(cb: (a: EventJson) => void) { private async _subscribe(cb: EventHandler) {
this.numRequests++; if (this.hasWebsocket()) {
if (this.websocket !== null) {
const subscription = await this.send('subscribe', {}, 60_000, false, undefined, (res) => { const subscription = await this.send('subscribe', {}, 60_000, false, undefined, (res) => {
if (res.action === 'push') { if (res.action === 'push') {
for (const event of res.data.events) { if (isEventPushEnvelopeJson(res.data)) {
cb(event); for (const event of res.data.events) {
cb(new Event(event));
}
} else {
throw new Error("Invalid server response");
} }
} }
}); });
@@ -464,7 +642,7 @@ export class Session {
} }
private async unsubscribe(requestNr: number | undefined = undefined, abortController: AbortController | undefined = undefined) { private async unsubscribe(requestNr: number | undefined = undefined, abortController: AbortController | undefined = undefined) {
if (this.websocket !== null) { if (this.hasWebsocket()) {
await this.send('unsubscribe', {'request_nr': requestNr}) await this.send('unsubscribe', {'request_nr': requestNr})
} else { } else {
if (abortController) abortController.abort('unsubscribe'); if (abortController) abortController.abort('unsubscribe');

View File

@@ -1,8 +1,9 @@
html { html {
font-family: 'Arial', sans-serif; font-family: 'Arial', sans-serif;
--bg-win: rgba(192, 192, 192, 0.25); --bg-win: #C0C0C040;
--bg: rgba(224, 224, 224, 0.5); --bg: #FFFFFF80;
--bg-border: #FFFFFFC0;
--fg-soft: rgba(32, 32, 32, 0.5); --fg-soft: rgba(32, 32, 32, 0.5);
--footer-height: 2em; --footer-height: 2em;
} }
@@ -57,15 +58,22 @@ main {
} }
#windows > * { #windows > * {
padding: 0.5em 1em; padding: 1em 2em;
} }
div.window-login, div.window-login,
div.window-welcome { div.window-welcome {
max-width: 650px;
margin: calc(max(var(--vh, 1vh) * 25, 8em) - 8em) auto 1em auto; margin: calc(max(var(--vh, 1vh) * 25, 8em) - 8em) auto 1em auto;
} }
div.window-login {
max-width: 450px;
}
div.window-welcome {
max-width: 650px;
}
div.window-login h1, div.window-login h1,
div.window-welcome h1 { div.window-welcome h1 {
text-align: center; text-align: center;
@@ -93,23 +101,23 @@ footer div {
form { form {
max-width: 400px; max-width: 400px;
margin: 1.5em auto; margin: 1.5em auto 1em auto;
} }
form input, form input:is([type=text], [type=password]),
form button, form button,
form fieldset,
a.button, a.button,
form div { form div {
border: 1px solid var(--bg); border: 1px solid var(--bg-border);
border-radius: 4px; border-radius: 4px;
outline: none;
padding: 0.5em 1em; padding: 0.5em 1em;
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
margin: 1em auto; margin: 1em auto;
font-size: 1em; font-size: 1em;
color: #000000; color: #000000;
transition: border-color 0.125s, background-color 0.125s; transition: border-color 0.0625s, background-color 0.0625s;
} }
form input, form input,
@@ -132,8 +140,9 @@ a.button {
width: 100px; width: 100px;
} }
form input, form input:is([type=text], [type=password]),
form button { form button,
form fieldset {
width: 100%; width: 100%;
} }