meteor/meteor

View on GitHub
packages/autoupdate/autoupdate_client.js

Summary

Maintainability
D
2 days
Test Coverage
// Subscribe to the `meteor_autoupdate_clientVersions` collection,
// which contains the set of acceptable client versions.
//
// A "hard code push" occurs when the running client version is not in
// the set of acceptable client versions (or the server updates the
// collection, there is a published client version marked `current` and
// the running client version is no longer in the set).
//
// When the `reload` package is loaded, a hard code push causes
// the browser to reload, so that it will load the latest client
// version from the server.
//
// A "soft code push" represents the situation when the running client
// version is in the set of acceptable versions, but there is a newer
// version available on the server.
//
// `Autoupdate.newClientAvailable` is a reactive data source which
// becomes `true` if a new version of the client is available on
// the server.
//
// This package doesn't implement a soft code reload process itself,
// but `newClientAvailable` could be used for example to display a
// "click to reload" link to the user.

// The client version of the client code currently running in the
// browser.

import { ClientVersions } from "./client_versions.js";

const clientArch = Meteor.isCordova ? "web.cordova" :
  Meteor.isModern ? "web.browser" : "web.browser.legacy";

const autoupdateVersions =
  ((__meteor_runtime_config__.autoupdate || {}).versions || {})[clientArch] || {
    version: "unknown",
    versionRefreshable: "unknown",
    versionNonRefreshable: "unknown",
    assets: [],
  };

export const Autoupdate = {};

// Stores acceptable client versions.
const clientVersions =
  Autoupdate._clientVersions = // Used by a self-test and hot-module-replacement
  new ClientVersions();

Meteor.connection.registerStore(
  "meteor_autoupdate_clientVersions",
  clientVersions.createStore()
);

Autoupdate.newClientAvailable = function () {
  return clientVersions.newClientAvailable(
    clientArch,
    ["versionRefreshable", "versionNonRefreshable"],
    autoupdateVersions
  );
};

// Set to true if the link.onload callback ever fires for any <link> node.
let knownToSupportCssOnLoad = false;

const retry = new Retry({
  // Unlike the stream reconnect use of Retry, which we want to be instant
  // in normal operation, this is a wacky failure. We don't want to retry
  // right away, we can start slowly.
  //
  // A better way than timeconstants here might be to use the knowledge
  // of when we reconnect to help trigger these retries. Typically, the
  // server fixing code will result in a restart and reconnect, but
  // potentially the subscription could have a transient error.
  minCount: 0, // don't do any immediate retries
  baseTimeout: 30*1000 // start with 30s
});

let failures = 0;

Autoupdate._retrySubscription = () => {
  Meteor.subscribe("meteor_autoupdate_clientVersions", {
    onError(error) {
      Meteor._debug("autoupdate subscription failed", error);
      failures++;
      retry.retryLater(failures, function () {
        // Just retry making the subscription, don't reload the whole
        // page. While reloading would catch more cases (for example,
        // the server went back a version and is now doing old-style hot
        // code push), it would also be more prone to reload loops,
        // which look really bad to the user. Just retrying the
        // subscription over DDP means it is at least possible to fix by
        // updating the server.
        Autoupdate._retrySubscription();
      });
    },

    onReady() {
      // Call checkNewVersionDocument with a slight delay, so that the
      // const handle declaration is guaranteed to be initialized, even if
      // the added or changed callbacks are called synchronously.
      const resolved = Promise.resolve();
      function check(doc) {
        resolved.then(() => checkNewVersionDocument(doc));
      }

      const stop = clientVersions.watch(check);

      function checkNewVersionDocument(doc) {
        if (doc._id !== clientArch) {
          return;
        }

        if (doc.versionNonRefreshable !==
            autoupdateVersions.versionNonRefreshable) {
          // Non-refreshable assets have changed, so we have to reload the
          // whole page rather than just replacing <link> tags.
          if (stop) stop();
          if (Package.reload) {
            // The reload package should be provided by ddp-client, which
            // is provided by the ddp package that autoupdate depends on.
            Package.reload.Reload._reload();
          }
          return;
        }

        if (doc.versionRefreshable !== autoupdateVersions.versionRefreshable) {
          autoupdateVersions.versionRefreshable = doc.versionRefreshable;

          // Switch out old css links for the new css links. Inspired by:
          // https://github.com/guard/guard-livereload/blob/master/js/livereload.js#L710
          var newCss = doc.assets || [];
          var oldLinks = [];

          Array.prototype.forEach.call(
            document.getElementsByTagName('link'),
            function (link) {
              if (link.className === '__meteor-css__') {
                oldLinks.push(link);
              }
            }
          );

          function waitUntilCssLoads(link, callback) {
            var called;

            link.onload = function () {
              knownToSupportCssOnLoad = true;
              if (! called) {
                called = true;
                callback();
              }
            };

            if (! knownToSupportCssOnLoad) {
              var id = Meteor.setInterval(function () {
                if (link.sheet) {
                  if (! called) {
                    called = true;
                    callback();
                  }
                  Meteor.clearInterval(id);
                }
              }, 50);
            }
          }

          let newLinksLeftToLoad = newCss.length;
          function removeOldLinks() {
            if (oldLinks.length > 0 &&
                --newLinksLeftToLoad < 1) {
              oldLinks.splice(0).forEach(link => {
                link.parentNode.removeChild(link);
              });
            }
          }

          if (newCss.length > 0) {
            newCss.forEach(css => {
              const newLink = document.createElement("link");
              newLink.setAttribute("rel", "stylesheet");
              newLink.setAttribute("type", "text/css");
              newLink.setAttribute("class", "__meteor-css__");
              newLink.setAttribute("href", css.url);

              waitUntilCssLoads(newLink, function () {
                Meteor.setTimeout(removeOldLinks, 200);
              });

              const head = document.getElementsByTagName("head").item(0);
              head.appendChild(newLink);
            });
          } else {
            removeOldLinks();
          }
        }
      }
    }
  });
};

Autoupdate._retrySubscription();