Newer
Older
CrypticOreWallet / src / krist / KristConnectionService.ts
import WebSocketAsPromised from "websocket-as-promised";
import WSPOptions from "websocket-as-promised/types/options";

import { KristApiWsResponse } from "./types/KristApiTypes";

import { sleep } from "@utils";

import Debug from "debug";
import { KristWsAnyMessage, KristWsResponseMessage } from "./types/ws/KristWsTypes";
import { KristWsApi } from "./KristWsApi";
import { KristAddress } from "./types/KristTypes";
const debug = Debug("kristweb:connection");

const WSP_OPTIONS: WSPOptions = {
  packMessage: data => JSON.stringify(data),
  unpackMessage: data => JSON.parse(data.toString())
};

/** Silly typing for an object containing a promise's resolve and reject
 * functions. */
interface PromiseResponseHandler<T> {
  resolve: (value?: T | PromiseLike<T>) => void;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  reject: (reason?: any) => void;
};

export class KristConnectionService {
  syncNode?: string;

  ws?: WebSocketAsPromised;
  connected = false;
  ready = false;

  /** The API wrapper */
  wsApi: KristWsApi;

  /** The Krist websocket API requires an ID on each message to the server, so
   * that the client knows which messages the server is responding to. This
   * counter is reset on each connection. */
  messageIDCounter = 0;
  /** When we send a message out, this map keeps track of our own message IDs,
   * and resolves the promises when the server responds to them. */
  messageResponses?: Map<number, PromiseResponseHandler<KristWsResponseMessage>>;

  /** Whether or not the current websocket is authenticated as a guest. */
  isGuest = false;
  /** The current address that the websocket is authenticated as, if any. */
  currentAddress?: KristAddress;

  constructor() {
    this.wsApi = new KristWsApi(this);
  }

  async connect(syncNode: string): Promise<void> {
    // Close any existing connection
    if (this.connected && this.ws)
      await this.ws.close();

    this.syncNode = syncNode;

    // Fetch the WS token URL. Remove trailing slash and grab /ws/start
    const connectionURL = this.syncNode.replace(/\/$/, "") + "/ws/start";
    const resp: KristApiWsResponse = await fetch(connectionURL, {
      method: "POST"
    }).then(r => r.json());
    if (!resp || !resp.ok || !resp.url) 
      throw new Error("Invalid WS response");

    // Prepare to receive responses
    this.messageResponses = new Map();

    this.ws = new WebSocketAsPromised(resp.url, WSP_OPTIONS);
    this.ws.onClose.addListener(this.onDisconnect.bind(this));
    this.ws.onUnpackedMessage.addListener(this.onUnpackedMessage.bind(this));

    await this.ws.open();
    this.connected = true;

    debug("connected");
  }

  async onDisconnect(event: CloseEvent): Promise<void> {
    this.connected = false;
    this.ready = false;
    debug(`disconnected: ${event.reason} (${event.code})`);

    // Reject any existing message requests
    this.messageIDCounter = 0;
    if (this.messageResponses) {
      for (const [, response] of this.messageResponses.entries()) {
        response.reject();
      }
      this.messageResponses.clear();
    }

    // Don't bother reconnecting if the syncNode was unset
    if (!this.syncNode) return;

    debug("reconnecting in 3 seconds");
    await sleep(3000);
    this.connect(this.syncNode);
  }

  /** Sends a message out to the Krist websocket server. This is for the lower
   * level interactions - use {@link KristWsApi} instead. */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  public sendMessage(type: string, data?: any): Promise<KristWsResponseMessage> {
    return new Promise((resolve, reject) => {
      if (!this.connected || !this.ws || !this.messageResponses) 
        return reject(new Error("Websocket not connected"));

      // Prepare to receive a response
      const messageID = ++this.messageIDCounter;
      this.messageResponses.set(messageID, { resolve, reject });

      // Attach the metadata to the message, creating it if it doesn't exist
      data = data || {};
      data.type = type;
      data.id = messageID;

      this.ws.sendPacked(data);
    });
  }

  /** When a JSON message is received from the websocket, decode it and
   * delegate it to the relevant event handlers. */
  onUnpackedMessage(data: KristWsAnyMessage): void {
    if ("type" in data) {
      // Regular event or server-sent message
      debug("event received from server: %O", data);

      if (data.type === "hello")
        this.ready = true;
    } else if ("ok" in data && "id" in data) {
      // Response to one of our sent messages
      debug("response received from server: %O", data);

      const id = data.id;
      const responses = this.messageResponses;
      if (!responses) 
        throw new Error("Message responses is uninitialised");

      const response = responses.get(id);
      if (!response)
        throw new Error("Received response from server twice, or for a message we never sent");

      // Resolve or reject our response handler depending on whether or not the
      // server gave us an error
      if (!data.ok || data.error)
        response.reject(data);
      else
        response.resolve(data);

      // We're done with the response handler now, remove it
      responses.delete(id);
    } else if ("ok" in data) {
      // Generic response with no type or ID
      debug("generic message received from server: %O", data);
    } else {
      debug("invalid message received from krist server: %O", data);
    }
  }
}

let kristServiceInstance: KristConnectionService;
export const kristService = (): KristConnectionService => {
  return kristServiceInstance // Use existing instance
    || (kristServiceInstance = new KristConnectionService());
};