trufflesuite/truffle

View on GitHub
packages/dashboard/lib/DashboardServer.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import express, { Application, NextFunction, Request, Response } from "express";
import path from "path";
import getPort from "get-port";
import open from "open";
import { v4 as uuid } from "uuid";
import { fetchAndCompile } from "@truffle/fetch-and-compile";
import { sha1 } from "object-hash";
import Config from "@truffle/config";
import {
  dashboardProviderMessageType,
  LogMessage,
  logMessageType
} from "@truffle/dashboard-message-bus-common";
import type { Compilation } from "@truffle/compile-common";
import { DashboardMessageBus } from "@truffle/dashboard-message-bus";
import { DashboardMessageBusClient } from "@truffle/dashboard-message-bus-client";
import cors from "cors";
import type { Server } from "http";
import debugModule from "debug";

export interface DashboardServerOptions {
  /** Port of the dashboard */
  port: number;

  /** Port of the message bus publish socket server */
  publishPort?: number;

  /** Port of the message bus subscribe socket server */
  subscribePort?: number;

  /** Host of the dashboard (default: localhost) */
  host?: string;

  /** Boolean indicating whether the POST /rpc endpoint should be exposed (default: true) */
  rpc?: boolean;

  /** Boolean indicating whether debug output should be logged (default: false) */
  verbose?: boolean;

  /** Boolean indicating whether whether starting the DashboardServer should automatically open the dashboard (default: true) */
  autoOpen?: boolean;
}

export class DashboardServer {
  port: number;
  host: string;
  rpc: boolean;
  verbose: boolean;
  autoOpen: boolean;
  frontendPath: string;

  private expressApp?: Application;
  private httpServer?: Server;
  private messageBus?: DashboardMessageBus;
  private client?: DashboardMessageBusClient;
  private configPublishPort?: number;
  private configSubscribePort?: number;

  boundTerminateListener: () => void;

  get subscribePort(): number | undefined {
    return this.messageBus?.subscribePort;
  }

  get publishPort(): number | undefined {
    return this.messageBus?.subscribePort;
  }

  constructor(options: DashboardServerOptions) {
    this.host = options.host ?? "localhost";
    this.port = options.port;
    this.configPublishPort = options.publishPort;
    this.configSubscribePort = options.subscribePort;
    this.rpc = options.rpc ?? true;
    this.verbose = options.verbose ?? false;
    this.autoOpen = options.autoOpen ?? true;
    this.frontendPath = path.join(__dirname, "dashboard-frontend");

    this.boundTerminateListener = () => this.stop();
  }

  async start() {
    if (this.httpServer?.listening) {
      return;
    }

    this.messageBus = await this.startMessageBus();

    this.expressApp = express();

    this.expressApp.use(cors());
    this.expressApp.use(express.json());

    this.expressApp.get("/ports", this.getPorts.bind(this));

    if (this.rpc) {
      await this.connectToMessageBus();
      this.expressApp.post("/rpc", this.postRpc.bind(this));
    }

    this.expressApp.get("/fetch-and-compile", async (req, res) => {
      const { address, networkId, etherscanApiKey } = req.query as Record<
        string,
        string
      >;
      let config;
      try {
        config = Config.detect();
        // we'll ignore errors as we only get the config for the api key
      } catch {}

      // a key provided in the browser takes precedence over on in the config
      let etherscanKey: undefined | string;
      if (etherscanApiKey) {
        etherscanKey = etherscanApiKey;
      } else if (config && config.etherscan !== undefined) {
        etherscanKey = config.etherscan.apiKey;
      }

      config = Config.default().merge({
        networks: {
          custom: { network_id: networkId }
        },
        network: "custom",
        etherscan: {
          apiKey: etherscanKey
        }
      });

      let result;
      try {
        result = (await fetchAndCompile(address, config)).compileResult;
      } catch (error) {
        if (!error.message.includes("No verified sources")) {
          throw error;
        }
      }
      if (result) {
        // we calculate hashes on the server because it is at times too
        // resource intensive for the browser and causes it to crash
        const hashes = result.compilations.map((compilation: Compilation) => {
          return sha1(compilation);
        });
        res.json({
          hashes,
          compilations: result.compilations
        });
      } else {
        res.json({ compilations: [] });
      }
    });

    this.expressApp.get("/analytics", (_req, res) => {
      const userConfig = Config.getUserConfig();
      res.json({
        enableAnalytics: userConfig.get("enableAnalytics"),
        analyticsSet: userConfig.get("analyticsSet"),
        analyticsMessageDateTime: userConfig.get("analyticsMessageDateTime")
      });
    });

    this.expressApp.put("/analytics", (req, _res) => {
      const { value } = req.body as { value: boolean };

      const userConfig = Config.getUserConfig();

      const uid = userConfig.get("uniqueId");
      if (!uid) userConfig.set("uniqueId", uuid());

      userConfig.set({
        enableAnalytics: !!value,
        analyticsSet: true,
        analyticsMessageDateTime: Date.now()
      });
    });

    this.expressApp.use(express.static(this.frontendPath));
    this.expressApp.get("*", (_req, res) => {
      res.sendFile("index.html", { root: this.frontendPath });
    });

    await new Promise<void>(resolve => {
      this.httpServer = this.expressApp!.listen(this.port, this.host, () => {
        if (this.autoOpen) {
          const host = this.host === "0.0.0.0" ? "localhost" : this.host;
          open(`http://${host}:${this.port}`);
        }
        resolve();
      });
    });
  }

  async stop() {
    this.messageBus?.off("terminate", this.boundTerminateListener);

    await Promise.all([
      this.client?.close(),
      this.messageBus?.terminate(),
      new Promise<void>(resolve => {
        this.httpServer?.close(() => resolve());
      })
    ]);
    delete this.client;
  }

  private getPorts(req: Request, res: Response) {
    if (!this.messageBus) {
      throw new Error("Message bus has not been started yet");
    }

    res.json({
      dashboardPort: this.port,
      subscribePort: this.messageBus.subscribePort,
      publishPort: this.messageBus.publishPort
    });
  }

  private postRpc(req: Request, res: Response, next: NextFunction) {
    if (!this.client) {
      throw new Error("Not connected to message bus");
    }

    this.client
      .publish({ type: dashboardProviderMessageType, payload: req.body })
      .then(lifecycle => lifecycle.response)
      .then(response => res.json(response?.payload))
      .catch(next);
  }

  private async startMessageBus() {
    const subscribePort =
      this.configSubscribePort ?? (await getPort({ host: this.host }));
    const publishPort =
      this.configPublishPort ?? (await getPort({ host: this.host }));

    const messageBus = new DashboardMessageBus(
      publishPort,
      subscribePort,
      this.host
    );

    await messageBus.start();
    messageBus.on("terminate", this.boundTerminateListener);

    return messageBus;
  }

  private async connectToMessageBus() {
    if (!this.messageBus) {
      throw new Error("Message bus has not been started yet");
    }

    if (this.client) {
      return;
    }

    this.client = new DashboardMessageBusClient({
      host: this.host,
      subscribePort: this.messageBus.subscribePort,
      publishPort: this.messageBus.publishPort
    });

    await this.client.ready();

    // the promise returned by `setupVerboseLogging` never resolves, so don't
    // bother awaiting it.
    this.setupVerboseLogging();
  }

  private async setupVerboseLogging(): Promise<void> {
    if (this.verbose && this.client) {
      this.client
        .subscribe({ type: logMessageType })
        .on("message", lifecycle => {
          if (lifecycle && lifecycle.message.type === "log") {
            const logMessage = lifecycle.message as LogMessage;
            const debug = debugModule(logMessage.payload.namespace);
            debug.enabled = true;
            debug(logMessage.payload.message);
          }
        });
    }
  }
}