meteor/meteor

View on GitHub
tools/runners/run-hmr.js

Summary

Maintainability
D
1 day
Test Coverage
import WS from 'ws';
import runLog from './run-log.js';
import crypto from 'crypto';
import Anser from "anser";
import { CordovaBuilder } from '../cordova/builder.js';

export class HMRServer {
  constructor({
    proxy, hmrPath, secret, projectContext, cordovaServerPort
}) {
    this.proxy = proxy;
    this.projectContext = projectContext;

    this.hmrPath = hmrPath;
    this.secret = secret;

    this.wsServer = null;
    this.connByArch = Object.create(null);
    this.started = false;

    this.changeSetsByArch = Object.create(null);

    this.maxChangeSets = 300;
    this.cacheKeys = Object.create(null);
    this.trimmedArchUntil = Object.create(null);
    this.firstBuild = null;

    if (!cordovaServerPort) {
     cordovaServerPort = CordovaBuilder.createCordovaServerPort(
          projectContext.appIdentifier
        );
    }

    this.cordovaOrigin = `http://localhost:${cordovaServerPort}`;
  }

  start() {
    if (!this.proxy.started) {
      throw new Error('Proxy must be started before HMR Server');
    }

    this.wsServer = new WS.Server({
      noServer: true,
    });
    this.proxy.server.on('upgrade', (req, res, head) => {
      if (req.url === this.hmrPath) {
        this.wsServer.handleUpgrade(req, res, head, (conn) => {
          this._handleWsConn(conn, req);
        });
      }
    });

    this.started = true;
  }

  stop() {
    this.wsServer.close();
    this.connByArch = Object.create(null);
  }

  _handleWsConn(conn, req) {
    let registered = false;
    let connArch = null;
    let fromCordova = this.cordovaOrigin && req.headers.origin === this.cordovaOrigin;

    conn.on('message', (_message) => {
      const message = JSON.parse(_message);

      switch (message.type) {
        case 'register': {
          const { arch, appId, secret = '' } = message;

          if (appId !== this.projectContext.appIdentifier) {
            // A different app is trying to request changes
            conn.send(JSON.stringify({
              type: 'register-failed',
              reason: 'wrong-app'
            }));
          }

          let secretsMatch = secret.length === this.secret.length &&
            crypto.timingSafeEqual(Buffer.from(secret), Buffer.from(this.secret));

          if (
            !fromCordova &&
            !secretsMatch
          ) {
            conn.send(JSON.stringify({
              type: 'register-failed',
              reason: 'wrong-secret'
            }));
            conn.close();
            return;
          }

          this.connByArch[arch] = this.connByArch[arch] || [];
          this.connByArch[arch].push(conn);
          connArch = arch;
          registered = true;
          break;
        }

        case 'request-changes': {
          if (!registered) {
            // Might have sent the wrong secret or be the wrong app
            // Even if we closed the connection, it might still handle
            // this message.
            return;
          }
          const { after, arch } = message;

          const trimmedUntil = this.trimmedArchUntil[arch] || Math.Infinity;
          if (trimmedUntil > after) {
            // We've removed changeSets needed for the client to update with HMR
            conn.send(
              JSON.stringify({
                type: 'changes',
                changeSets: [
                  { reloadable: false }
                ]
              })
            );
            return;
          }

          const archChangeSets = this.changeSetsByArch[arch] || [];
          const newChanges = archChangeSets.filter(({ linkedAt }) => {
            return linkedAt > after;
          });

          conn.send(JSON.stringify({
            type: 'changes',
            changeSets: newChanges
          }));

          break;
        }

        default:
          throw new Error(`Unknown HMR message ${message.type}`);
      }
    });

    // TODO: should use pings to detect disconnected sockets
    conn.on('close', () => {
      if (!connArch) {
        return;
      }

      const archConns = this.connByArch[connArch] || [];
      const index = archConns.indexOf(conn);
      if (index > -1) {
        archConns.splice(
          index,
          1
        );
      }
    });
  }

  _sendAll(message) {
    Object.values(this.connByArch).forEach(conns => {
      conns.forEach(conn => {
        conn.send(JSON.stringify(message));
      });
    });
  }

  setAppState(state) {
    if (state === 'error') {
      const lines = runLog.getLog().map(line => {
        return Anser.ansiToHtml(Anser.escapeForHtml(line.message))
      });
      this._sendAll({
        type: 'app-state',
        state: 'error',
        log: lines
      });
    } else if (state === 'okay') {
      this._sendAll({
        type: 'app-state',
        state: 'okay'
      });
    }
  }

  compare({ name, arch, hmrAvailable, files, cacheKey }, getFileOutput) {
    if (this.firstBuild = null) {
      this.firstBuild = Date.now();
    }

    this.changeSetsByArch[arch] = this.changeSetsByArch[arch] || [];
    const previousCacheKey = this.cacheKeys[`${arch}-${name}`];

    if (previousCacheKey === cacheKey) {
      return;
    }

    // Try to do HMR without waiting for the build to finish
    // If it fails, the client will retry after the build finishes so
    // it can fall back to hot code push
    const sendEagerUpdate = (changeset) => {
      if (!this.connByArch[arch]) {
        return;
      }

      this.connByArch[arch].forEach(conn => {
        conn.send(JSON.stringify({
          type: 'changes',
          changeSets: [changeset],
          eager: true
        }));
      });
    }

    this.cacheKeys[`${arch}-${name}`] = cacheKey;
    const previous = this.findLastChangeset(name, arch) || {};

    if (!hmrAvailable) {
      let changeset = {
        name,
        reloadable: false,
        cacheKey,
        // TODO: use more accurate name
        linkedAt: Date.now()
      };
      this.changeSetsByArch[arch].push(changeset);
      this._trimChangeSets(arch);
      sendEagerUpdate(changeset);
      return;
    }

    const {
      addedFiles,
      changedFiles,
      removedFilePaths,
      unreloadable,
      onlyReplaceableChanges,
      fileHashes
    } = this.compareFiles(
      previous.fileHashes,
      previous.unreloadableHashes,
      files
    );

    const couldCompare = !!previous.fileHashes
    const reloadable = couldCompare &&
      onlyReplaceableChanges &&
      removedFilePaths.length === 0;

    function saveFileDetails(file) {
      return {
        content: getFileOutput(file).toStringWithSourceMap({}),
        path: file.absModuleId,
        meteorInstallOptions: file.meteorInstallOptions
      };
    }

    const result = {
      fileHashes,
      unreloadableHashes: unreloadable,
      reloadable,
      addedFiles: reloadable ? addedFiles.map(saveFileDetails) : [],
      changedFiles: reloadable ? changedFiles.map(saveFileDetails) : [],
      linkedAt: Date.now(),
      id: this._createId(),
      name
    };

    // TODO: we should also store the latest change set
    // for each arch and name someplace else so it doesn't
    // get removed when trimming changesets
    this.changeSetsByArch[arch].push(result);
    this._trimChangeSets(arch);

    if (!(arch in this.trimmedArchUntil)) {
      this.trimmedArchUntil[arch] = this.firstBuild - 1;
    }

    sendEagerUpdate(result);
  }

  _trimChangeSets(arch) {
    if (this.changeSetsByArch[arch].length > this.maxChangeSets) {
      const removed = this.changeSetsByArch[arch].splice(
        0,
        this.changeSetsByArch[arch].length - this.maxChangeSets
      );
      this.trimmedArchUntil[arch] = removed[removed.length - 1].linkedAt;
    }
  }

  _createId() {
    return `${Date.now()}-${Math.random()}`;
  }

  _checkReloadable(file) {
    return file.absModuleId &&
      !file.bare &&
      // TODO: support jsonData
      !file.jsonData &&
      file.meteorInstallOptions
  }

  compareFiles(previousHashes = new Map(), previousUnreloadable = [], currentFiles) {
    const unreloadable = [];
    const currentHashes = new Map();
    const unseenModules = new Map(previousHashes);

    const changedFiles = [];
    const addedFiles = [];
    let onlyReplaceableChanges = true;

    currentFiles.forEach(file => {
      let fileConfig;
      let ignoreHash = false;

      if (file.targetPath !== file.sourcePath && file.implicit) {
        // The import scanner created this file as an alias to the target path
        // This file's content does not change when the hash does, only the
        // content of the new file created at the target path.
        ignoreHash = true;
        fileConfig = JSON.stringify({
          implicit: file.implicit,
          sourcePath: file.sourcePath,
          targetPath: file.targetPath
        });
      } else {
        fileConfig = JSON.stringify({
          meteorInstallOptions: file.meteorInstallOptions,
          absModuleId: file.absModuleId,
          sourceMap: !!file.sourceMap,
          mainModule: file.mainModule,
          imported: file.imported,
          alias: file.alias,
          lazy: file.lazy,
          bare: file.bare
        })
      }

      if (
        !this._checkReloadable(file)
      ) {
        unreloadable.push(`${fileConfig}-${file._inputHash}`);
        return;
      }

      currentHashes.set(file.absModuleId, {
        inputHash: file._inputHash,
        config: fileConfig
      });

      const {
        inputHash: previousInputHash,
        config: previousConfig
      } = previousHashes.get(file.absModuleId) || {};

      if (!previousInputHash) {
        addedFiles.push(file);
      } else if (previousConfig !== fileConfig) {
        onlyReplaceableChanges = false;
      } else if (!ignoreHash && previousInputHash !== file._inputHash) {
        changedFiles.push(file);
      }

      unseenModules.delete(file.absModuleId);
    });

    const removedFilePaths = Array.from(unseenModules.keys());
    if (onlyReplaceableChanges) {
      const unreloadableChanged = unreloadable.length !== previousUnreloadable.length ||
        unreloadable.some((hash, i) => hash !== previousUnreloadable[i]);
      onlyReplaceableChanges = !unreloadableChanged;
    }

    return {
      fileHashes: currentHashes,
      addedFiles,
      changedFiles,
      removedFilePaths,
      unreloadable,
      onlyReplaceableChanges,
    };
  }

  findLastChangeset(name, arch) {
    const changeSets = this.changeSetsByArch[arch] || [];
    for (let i = changeSets.length - 1; i >= 0; i--) {
      if (changeSets[i].name === name) {
        return changeSets[i];
      }
    }
  }
}