Rewrite autoupdate to distinguish versions by client architecture.

Now that we're postponing the legacy build until after the first client
refresh message is sent, there's a risk that changes to the legacy build
will not be picked up until after the next rebuild.

If we attempted to fix that problem by sending the refresh message after
the legacy bundle is rebuilt, then we would lose most of the benefit of
delaying the legacy build, because the client would not refresh until
after the legacy build completed.

The right way to fix the problem is by sending a second client refresh
message after the legacy build finishes, but doing so with the current
autoupdate implementation would very likely cause modern clients to reload
a second time.

The solution implemented by this commit is simple in theory: the
autoupdate package should keep track of distinct versions for each client
architecture, so that modern clients will refresh only when the modern
versions change, and legacy clients will refresh only when the legacy
versions change, which allows us to send two refresh messages without
causing any clients to refresh more than once.

In reality, this was a fairly major rewrite, since the ClientVersions
collection has a totally different schema now. I've tested it as well as I
can, though I'm not entirely sure what will happen if clients using the
previous version of the autoupdate package begin receiving DDP messages
from this version of the autoupdate server code.
This commit is contained in:
Ben Newman
2018-07-06 12:51:47 -04:00
parent e3082a43f6
commit fe9e4035f9
5 changed files with 201 additions and 223 deletions

View File

@@ -24,28 +24,46 @@
// The client version of the client code currently running in the
// browser.
var autoupdateVersion = __meteor_runtime_config__.autoupdateVersion || "unknown";
var autoupdateVersionRefreshable =
__meteor_runtime_config__.autoupdateVersionRefreshable || "unknown";
// The collection of acceptable client versions.
ClientVersions = new Mongo.Collection("meteor_autoupdate_clientVersions");
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: [],
};
Autoupdate = {};
// The collection of acceptable client versions.
ClientVersions =
Autoupdate._ClientVersions = // Used by a self-test.
new Mongo.Collection("meteor_autoupdate_clientVersions");
Autoupdate.newClientAvailable = function () {
return !! ClientVersions.findOne({
_id: "version",
version: {$ne: autoupdateVersion} }) ||
!! ClientVersions.findOne({
_id: "version-refreshable",
version: {$ne: autoupdateVersionRefreshable} });
return !! (
ClientVersions.findOne({
_id: clientArch,
versionNonRefreshable: {
$ne: autoupdateVersions.versionNonRefreshable,
}
}) ||
ClientVersions.findOne({
_id: clientArch,
versionRefreshable: {
$ne: autoupdateVersions.versionRefreshable,
}
})
);
};
Autoupdate._ClientVersions = ClientVersions; // Used by a self-test
var knownToSupportCssOnLoad = false;
// Set to true if the link.onload callback ever fires for any <link> node.
let knownToSupportCssOnLoad = false;
var retry = new Retry({
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.
@@ -57,19 +75,12 @@ var retry = new Retry({
minCount: 0, // don't do any immediate retries
baseTimeout: 30*1000 // start with 30s
});
var failures = 0;
function after(times, func) {
return function() {
if (--times < 1) {
return func.apply(this, arguments);
}
};
};
let failures = 0;
Autoupdate._retrySubscription = function () {
Autoupdate._retrySubscription = () => {
Meteor.subscribe("meteor_autoupdate_clientVersions", {
onError: function (error) {
onError(error) {
Meteor._debug("autoupdate subscription failed", error);
failures++;
retry.retryLater(failures, function () {
@@ -83,94 +94,104 @@ Autoupdate._retrySubscription = function () {
Autoupdate._retrySubscription();
});
},
onReady: function () {
if (Package.reload) {
var checkNewVersionDocument = function (doc) {
var self = this;
if (doc._id === 'version-refreshable' &&
doc.version !== autoupdateVersionRefreshable) {
autoupdateVersionRefreshable = doc.version;
// 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 && doc.assets.allCss) || [];
var oldLinks = [];
Array.prototype.forEach.call(
document.getElementsByTagName('link'),
function (link) {
if (link.className === '__meteor-css__') {
oldLinks.push(link);
}
}
);
onReady() {
const handle = ClientVersions.find().observe({
added: checkNewVersionDocument,
changed: checkNewVersionDocument
});
function waitUntilCssLoads(link, callback) {
var called;
function executeCallback(...args) {
if (! called) {
called = true;
return callback(...args);
}
}
function checkNewVersionDocument(doc) {
if (doc._id !== clientArch) {
return;
}
link.onload = function () {
knownToSupportCssOnLoad = true;
executeCallback();
};
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 (handle) handle.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 (! knownToSupportCssOnLoad) {
var id = Meteor.setInterval(function () {
if (link.sheet) {
executeCallback();
Meteor.clearInterval(id);
}
}, 50);
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);
}
}
);
var removeOldLinks = after(newCss.length, function () {
oldLinks.forEach(function (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);
});
});
}
}
var attachStylesheetLink = function (newLink) {
document.getElementsByTagName("head").item(0).appendChild(newLink);
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);
});
};
if (newCss.length !== 0) {
newCss.forEach(function (css) {
var newLink = document.createElement("link");
newLink.setAttribute("rel", "stylesheet");
newLink.setAttribute("type", "text/css");
newLink.setAttribute("class", "__meteor-css__");
newLink.setAttribute("href", css.url);
attachStylesheetLink(newLink);
});
} else {
removeOldLinks();
}
const head = document.getElementsByTagName("head").item(0);
head.appendChild(newLink);
});
} else {
removeOldLinks();
}
else if (doc._id === 'version' && doc.version !== autoupdateVersion) {
handle && handle.stop();
if (Package.reload) {
Package.reload.Reload._reload();
}
}
};
var handle = ClientVersions.find().observe({
added: checkNewVersionDocument,
changed: checkNewVersionDocument
});
}
}
}
});
};
Autoupdate._retrySubscription();

View File

@@ -1,16 +1,20 @@
var autoupdateVersionCordova = __meteor_runtime_config__.autoupdateVersionCordova || "unknown";
var autoupdateVersionsCordova =
__meteor_runtime_config__.autoupdate.versions["web.cordova"] || {
version: "unknown"
};
// The collection of acceptable client versions.
ClientVersions = new Mongo.Collection("meteor_autoupdate_clientVersions");
Autoupdate = {};
Autoupdate.newClientAvailable = function() {
return !! ClientVersions.findOne({
_id: 'version-cordova',
version: {$ne: autoupdateVersionCordova}
Autoupdate.newClientAvailable =
() => !! ClientVersions.findOne({
_id: "web.cordova",
version: {
$ne: autoupdateVersionsCordova.version
}
});
};
var retry = new Retry({
// Unlike the stream reconnect use of Retry, which we want to be instant
@@ -24,12 +28,14 @@ var retry = new Retry({
minCount: 0, // don't do any immediate retries
baseTimeout: 30*1000 // start with 30s
});
var failures = 0;
Autoupdate._retrySubscription = function() {
var appId = __meteor_runtime_config__.appId;
let failures = 0;
Autoupdate._retrySubscription = () => {
const { appId } = __meteor_runtime_config__;
Meteor.subscribe("meteor_autoupdate_clientVersions", appId, {
onError: function(error) {
onError(error) {
console.log("autoupdate subscription failed:", error);
failures++;
retry.retryLater(failures, function() {
@@ -43,16 +49,18 @@ Autoupdate._retrySubscription = function() {
Autoupdate._retrySubscription();
});
},
onReady: function() {
onReady() {
if (Package.reload) {
var checkNewVersionDocument = function(doc) {
var self = this;
if (doc.version !== autoupdateVersionCordova) {
function checkNewVersionDocument(doc) {
if (doc.version !== autoupdateVersionsCordova.version) {
newVersionAvailable();
}
};
}
var handle = ClientVersions.find({_id: 'version-cordova'}).observe({
ClientVersions.find({
_id: "web.cordova"
}).observe({
added: checkNewVersionDocument,
changed: checkNewVersionDocument
});
@@ -61,8 +69,8 @@ Autoupdate._retrySubscription = function() {
});
};
Meteor.startup(function() {
WebAppLocalServer.onNewVersionReady(function() {
Meteor.startup(() => {
WebAppLocalServer.onNewVersionReady(() => {
if (Package.reload) {
Package.reload.Reload._reload();
}
@@ -71,6 +79,6 @@ Meteor.startup(function() {
Autoupdate._retrySubscription();
});
var newVersionAvailable = function() {
function newVersionAvailable() {
WebAppLocalServer.checkForUpdates();
}

View File

@@ -1,39 +1,38 @@
// Publish the current client versions to the client. When a client
// sees the subscription change and that there is a new version of the
// client available on the server, it can reload.
// Publish the current client versions for each client architecture
// (web.browser, web.browser.legacy, web.cordova). When a client observes
// a change in the versions associated with its client architecture,
// it will refresh itself, either by swapping out CSS assets or by
// reloading the page.
//
// By default there are two current client versions. The refreshable client
// version is identified by a hash of the client resources seen by the browser
// that are refreshable, such as CSS, while the non refreshable client version
// is identified by a hash of the rest of the client assets
// (the HTML, code, and static files in the `public` directory).
// There are three versions for any given client architecture: `version`,
// `versionRefreshable`, and `versionNonRefreshable`. The refreshable
// version is a hash of just the client resources that are refreshable,
// such as CSS, while the non-refreshable version is a hash of the rest of
// the client assets, excluding the refreshable ones: HTML, JS, and static
// files in the `public` directory. The `version` version is a combined
// hash of everything.
//
// If the environment variable `AUTOUPDATE_VERSION` is set it will be
// used as the client id instead. You can use this to control when
// the client reloads. For example, if you want to only force a
// reload on major changes, you can use a custom AUTOUPDATE_VERSION
// which you only change when something worth pushing to clients
// immediately happens.
// If the environment variable `AUTOUPDATE_VERSION` is set, it will be
// used in place of all client versions. You can use this variable to
// control when the client reloads. For example, if you want to force a
// reload only after major changes, use a custom AUTOUPDATE_VERSION and
// change it only when something worth pushing to clients happens.
//
// The server publishes a `meteor_autoupdate_clientVersions`
// collection. There are two documents in this collection, a document
// with _id 'version' which represents the non refreshable client assets,
// and a document with _id 'version-refreshable' which represents the
// refreshable client assets. Each document has a 'version' field
// which is equivalent to the hash of the relevant assets. The refreshable
// document also contains a list of the refreshable assets, so that the client
// can swap in the new assets without forcing a page refresh. Clients can
// observe changes on these documents to detect when there is a new
// version available.
//
// In this implementation only two documents are present in the collection
// the current refreshable client version and the current nonRefreshable client
// version. Developers can easily experiment with different versioning and
// updating models by forking this package.
// The server publishes a `meteor_autoupdate_clientVersions` collection.
// The ID of each document is the client architecture, and the fields of
// the document are the versions described above.
var Future = Npm.require("fibers/future");
Autoupdate = {};
Autoupdate = __meteor_runtime_config__.autoupdate = {
// Map from client architectures (web.browser, web.browser.legacy,
// web.cordova) to version fields { version, versionRefreshable,
// versionNonRefreshable, refreshable } that will be stored in
// ClientVersions documents (whose IDs are client architectures). This
// data gets serialized into the boilerplate because it's stored in
// __meteor_runtime_config__.autoupdate.versions.
versions: {}
};
// The collection of acceptable client versions.
ClientVersions = new Mongo.Collection("meteor_autoupdate_clientVersions",
@@ -53,91 +52,47 @@ Autoupdate.appId = __meteor_runtime_config__.appId = process.env.APP_ID;
var syncQueue = new Meteor._SynchronousQueue();
// updateVersions can only be called after the server has fully loaded.
var updateVersions = function (shouldReloadClientProgram) {
// Step 1: load the current client program on the server and update the
// hash values in __meteor_runtime_config__.
function updateVersions(shouldReloadClientProgram) {
// Step 1: load the current client program on the server
if (shouldReloadClientProgram) {
WebAppInternals.reloadClientPrograms();
}
// If we just re-read the client program, or if we don't have an autoupdate
// version, calculate it.
if (shouldReloadClientProgram || Autoupdate.autoupdateVersion === null) {
Autoupdate.autoupdateVersion =
process.env.AUTOUPDATE_VERSION ||
WebApp.calculateClientHashNonRefreshable();
}
// If we just recalculated it OR if it was set by (eg) test-in-browser,
// ensure it ends up in __meteor_runtime_config__.
__meteor_runtime_config__.autoupdateVersion =
Autoupdate.autoupdateVersion;
// Step 2: update __meteor_runtime_config__.autoupdate.versions.
const clientArchs = Object.keys(WebApp.clientPrograms);
clientArchs.forEach(arch => {
Autoupdate.versions[arch] = {
version: WebApp.calculateClientHashNonRefreshable(arch),
versionRefreshable: WebApp.calculateClientHashRefreshable(arch),
versionNonRefreshable:
WebApp.calculateClientHashNonRefreshable(arch),
};
});
Autoupdate.autoupdateVersionRefreshable =
__meteor_runtime_config__.autoupdateVersionRefreshable =
process.env.AUTOUPDATE_VERSION ||
WebApp.calculateClientHashRefreshable();
Autoupdate.autoupdateVersionCordova =
__meteor_runtime_config__.autoupdateVersionCordova =
process.env.AUTOUPDATE_VERSION ||
WebApp.calculateClientHashCordova();
// Step 2: form the new client boilerplate which contains the updated
// Step 3: form the new client boilerplate which contains the updated
// assets and __meteor_runtime_config__.
if (shouldReloadClientProgram) {
WebAppInternals.generateBoilerplate();
}
// XXX COMPAT WITH 0.8.3
if (! ClientVersions.findOne({current: true})) {
// To ensure apps with version of Meteor prior to 0.9.0 (in
// which the structure of documents in `ClientVersions` was
// different) also reload.
ClientVersions.insert({current: true});
}
if (! ClientVersions.findOne({_id: "version"})) {
ClientVersions.insert({
_id: "version",
version: Autoupdate.autoupdateVersion
});
} else {
ClientVersions.update("version", { $set: {
version: Autoupdate.autoupdateVersion
}});
}
if (! ClientVersions.findOne({_id: "version-cordova"})) {
ClientVersions.insert({
_id: "version-cordova",
version: Autoupdate.autoupdateVersionCordova,
refreshable: false
});
} else {
ClientVersions.update("version-cordova", { $set: {
version: Autoupdate.autoupdateVersionCordova
}});
}
// Use `onListening` here because we need to use
// `WebAppInternals.refreshableAssets`, which is only set after
// Step 4: update the ClientVersions collection.
// We use `onListening` here because we need to use
// `WebApp.getRefreshableAssets`, which is only set after
// `WebApp.generateBoilerplate` is called by `main` in webapp.
WebApp.onListening(function () {
if (! ClientVersions.findOne({_id: "version-refreshable"})) {
ClientVersions.insert({
_id: "version-refreshable",
version: Autoupdate.autoupdateVersionRefreshable,
assets: WebAppInternals.refreshableAssets
});
} else {
ClientVersions.update("version-refreshable", { $set: {
version: Autoupdate.autoupdateVersionRefreshable,
assets: WebAppInternals.refreshableAssets
}});
}
WebApp.onListening(() => {
clientArchs.forEach(arch => {
const payload = {
...Autoupdate.versions[arch],
assets: WebApp.getRefreshableAssets(arch),
};
if (! ClientVersions.findOne({ _id: arch })) {
ClientVersions.insert({ _id: arch, ...payload });
} else {
ClientVersions.update(arch, { $set: payload });
}
});
});
};
}
Meteor.publish(
"meteor_autoupdate_clientVersions",

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "Update the client when new client code is available",
version: '1.4.1'
version: '1.5.0'
});
Package.onUse(function (api) {

View File

@@ -206,12 +206,6 @@ Meteor.startup(function () {
].versionNonRefreshable;
};
WebApp.calculateClientHashCordova = function () {
return (WebApp.clientPrograms["web.cordova"] || {
version: "none"
}).version;
};
WebApp.getRefreshableAssets = function (arch) {
return WebApp.clientPrograms[
arch || WebApp.defaultArch