Calculate the client hash in webapp so that autoupdate doesn't have to

be a strong dependency of appcache.

Delaying publishing the current client version document until the auto
update version is available isn't necessary because the publish
callback won't be called before webapp starts listening, and that
happens after startup.

In the app cache manifest include both the client hash and the
AUTOUPDATE_VERSION, which both allows the browser to load new
client code even when the auto update version is explicitly set by the
developer, and also ensures that if the developer changes the auto
update version this is propagated to the client in the app HTML so
that autoupdate doesn't get into an infinite loop of reloads.
This commit is contained in:
Andrew Wilcox
2013-11-11 16:50:59 -05:00
committed by Nick Martin
parent 572fc333c7
commit e27e2d8c82
5 changed files with 166 additions and 138 deletions

View File

@@ -84,84 +84,95 @@ WebApp.connectHandlers.use(function(req, res, next) {
return;
}
var manifest = "CACHE MANIFEST\n\n";
// After the browser has downloaded the app files from the server and
// has populated the browser's application cache, the browser will
// *only* connect to the server and reload the application if the
// *contents* of the app manifest file has changed.
//
// So to ensure that the client updates if the auto update client
// version id changes (which defaults to a hash of the client
// resources), include the version id in the manifest.
// So to ensure that the client updates if client resources change,
// include a hash of client resources in the manifest.
AutoUpdate.withAutoUpdateVersion(function (autoUpdateVersion) {
manifest += "# " + WebApp.clientHash + "\n";
var manifest = "CACHE MANIFEST\n\n";
manifest += '# ' + autoUpdateVersion + "\n\n";
// When using the autoupdate package, also include
// AUTOUPDATE_VERSION. Otherwise the client will get into an
// infinite loop of reloads when the browser doesn't fetch the new
// app HTML which contains the new version, and autoupdate will
// reload again trying to get the new code.
manifest += "CACHE:" + "\n";
manifest += "/" + "\n";
_.each(WebApp.clientProgram.manifest, function (resource) {
if (resource.where === 'client' &&
! RoutePolicy.classify(resource.url)) {
manifest += resource.url;
// If the resource is not already cacheable (has a query
// parameter, presumably with a hash or version of some sort),
// put a version with a hash in the cache.
//
// Avoid putting a non-cacheable asset into the cache, otherwise
// the user can't modify the asset until the cache headers
// expire.
if (!resource.cacheable)
manifest += "?" + resource.hash;
if (Package.autoupdate) {
var version = Package.autoupdate.AutoUpdate.autoUpdateVersion;
if (version !== WebApp.clientHash)
manifest += "# " + version + "\n";
}
manifest += "\n";
}
});
manifest += "\n";
manifest += "\n";
manifest += "CACHE:" + "\n";
manifest += "/" + "\n";
_.each(WebApp.clientProgram.manifest, function (resource) {
if (resource.where === 'client' &&
! RoutePolicy.classify(resource.url)) {
manifest += resource.url;
// If the resource is not already cacheable (has a query
// parameter, presumably with a hash or version of some sort),
// put a version with a hash in the cache.
//
// Avoid putting a non-cacheable asset into the cache, otherwise
// the user can't modify the asset until the cache headers
// expire.
if (!resource.cacheable)
manifest += "?" + resource.hash;
manifest += "FALLBACK:\n";
manifest += "/ /" + "\n";
// Add a fallback entry for each uncacheable asset we added above.
//
// This means requests for the bare url (/image.png instead of
// /image.png?hash) will work offline. Online, however, the browser
// will send a request to the server. Users can remove this extra
// request to the server and have the asset served from cache by
// specifying the full URL with hash in their code (manually, with
// some sort of URL rewriting helper)
_.each(WebApp.clientProgram.manifest, function (resource) {
if (resource.where === 'client' &&
! RoutePolicy.classify(resource.url) &&
!resource.cacheable) {
manifest += resource.url + " " + resource.url +
"?" + resource.hash + "\n";
}
});
manifest += "\n";
manifest += "NETWORK:\n";
// TODO adding the manifest file to NETWORK should be unnecessary?
// Want more testing to be sure.
manifest += "/app.manifest" + "\n";
_.each(
[].concat(
RoutePolicy.urlPrefixesFor('network'),
RoutePolicy.urlPrefixesFor('static-online')
),
function (urlPrefix) {
manifest += urlPrefix + "\n";
}
);
manifest += "*" + "\n";
// content length needs to be based on bytes
var body = new Buffer(manifest);
res.setHeader('Content-Type', 'text/cache-manifest');
res.setHeader('Content-Length', body.length);
return res.end(body);
manifest += "\n";
}
});
manifest += "\n";
manifest += "FALLBACK:\n";
manifest += "/ /" + "\n";
// Add a fallback entry for each uncacheable asset we added above.
//
// This means requests for the bare url (/image.png instead of
// /image.png?hash) will work offline. Online, however, the browser
// will send a request to the server. Users can remove this extra
// request to the server and have the asset served from cache by
// specifying the full URL with hash in their code (manually, with
// some sort of URL rewriting helper)
_.each(WebApp.clientProgram.manifest, function (resource) {
if (resource.where === 'client' &&
! RoutePolicy.classify(resource.url) &&
!resource.cacheable) {
manifest += resource.url + " " + resource.url +
"?" + resource.hash + "\n";
}
});
manifest += "\n";
manifest += "NETWORK:\n";
// TODO adding the manifest file to NETWORK should be unnecessary?
// Want more testing to be sure.
manifest += "/app.manifest" + "\n";
_.each(
[].concat(
RoutePolicy.urlPrefixesFor('network'),
RoutePolicy.urlPrefixesFor('static-online')
),
function (urlPrefix) {
manifest += urlPrefix + "\n";
}
);
manifest += "*" + "\n";
// content length needs to be based on bytes
var body = new Buffer(manifest);
res.setHeader('Content-Type', 'text/cache-manifest');
res.setHeader('Content-Length', body.length);
return res.end(body);
});
var sizeCheck = function() {

View File

@@ -7,7 +7,7 @@ Package.on_use(function (api) {
api.use('reload', 'client');
api.use('routepolicy', 'server');
api.use('underscore', 'server');
api.use('autoupdate', 'server');
api.use('autoupdate', 'server', {weak: true});
api.add_files('appcache-client.js', 'client');
api.add_files('appcache-server.js', 'server');
});

View File

@@ -23,20 +23,8 @@ client code changes) by restarting the server with a new value for
`AUTOUPDATE_VERSION`.
## Test with the appcache
Add the appcache package:
$ meteor add appcache
And do the above "Hot Code Push Reload" test again.
## No Client Reload on Server-only Change
Undo previous changes made, such as by using `git checkout .` Reload
the client, which will cause the browser to stop using the app cache.
Note that it might look like the browser is reloading because the page
content in the leaderboard example will flicker when the server
restarts because the example is using autopublish, but that the window
@@ -50,8 +38,29 @@ and add `foo.js`. See in the browser console that `a` is still
defined, indicating the browser hasn't reloaded.
## Test with the appcache
Add the appcache package:
$ meteor add appcache
And do the above tests again.
Note that if 1) AUTOUPDATE_VERSION is set so the client doesn't
automatically reload, 2) you make a client change, and 3) you manually
reload the browser page, you usually *won't* see the updated HTML the
*first* time you reload (unless the browser happened to check the app
cache manifest between steps 2 and 3). This is normal browser app
cache behavior: the browser populates the app cache in the background,
so it doesn't wait for new files to download before displaying the web
page.
## AutoUpdate.newClientAvailable
Undo previous changes made, such as by using `git checkout .` Reload
the client, which will cause the browser to stop using the app cache.
It's hard to see the `newClientAvailable` reactive variable when the
client automatically reloads. Remove the `reload` package so you can
see the variable without having the client also reload.

View File

@@ -3,73 +3,37 @@ var crypto = Npm.require('crypto');
AutoUpdate = {};
// Everything that goes into the client code and resources as
// downloaded by the browser.
// The client hash includes __meteor_runtime_config__, so wait until
// all packages have loaded and have had a chance to populate the
// runtime config before using the client hash as our default auto
// update version id.
var calculateClientHash = function () {
var hash = crypto.createHash('sha1');
hash.update(JSON.stringify(__meteor_runtime_config__), 'utf8');
_.each(WebApp.clientProgram.manifest, function (resource) {
if (resource.where === 'client' || resource.where === 'internal') {
hash.update(resource.hash);
}
});
return hash.digest('hex');
};
AutoUpdate.autoUpdateVersion = null;
Meteor.startup(function () {
AutoUpdate.autoUpdateVersion =
process.env.AUTOUPDATE_VERSION ||
process.env.SERVER_ID ||
WebApp.clientHash;
// We need to calculate the autoupdate version after all packages have
// loaded and have had an opportunity to update
// `__meteor_runtime_config__`, so it's possible a subscription might
// get started before we have the version available.
var autoUpdateVersion = null;
// Subscriptions waiting for the autoupdate version to become
// available.
var callbacks = [];
// Calls the callback `cb` when the version is available.
AutoUpdate.withAutoUpdateVersion = function (cb) {
if (autoUpdateVersion === null)
callbacks.push(cb);
else
cb(autoUpdateVersion);
};
// also make the autoUpdateVersion available on the client.
__meteor_runtime_config__.autoUpdateVersion = AutoUpdate.autoUpdateVersion;
});
Meteor.publish(
"meteor_autoupdate_clientVersions",
function () {
var self = this;
AutoUpdate.withAutoUpdateVersion(function (autoUpdateVersion) {
self.added(
"meteor_autoupdate_clientVersions",
autoUpdateVersion,
{current: true}
);
self.ready();
});
// Using `autoUpdateVersion` here is safe because we can't get a
// subscription before webapp starts listening, and it doesn't do
// that until the startup hooks have run.
self.added(
"meteor_autoupdate_clientVersions",
AutoUpdate.autoUpdateVersion,
{current: true}
);
self.ready();
},
{is_auto: true}
);
// Wait until all packages have loaded and have had a chance to
// populate __meteor_runtime_config__.
Meteor.startup(function () {
autoUpdateVersion =
process.env.AUTOUPDATE_VERSION ||
process.env.SERVER_ID ||
calculateClientHash();
__meteor_runtime_config__.autoUpdateVersion = autoUpdateVersion;
while (callbacks.length > 0)
callbacks.shift()(autoUpdateVersion);
});

View File

@@ -151,6 +151,50 @@ var appUrl = function (url) {
return true;
};
// Calculate a hash of all the client resources downloaded by the
// browser, including the application HTML, runtime config, code, and
// static files.
//
// This hash *must* change if any resources seen by the browser
// change, and ideally *doesn't* change for any server-only changes
// (but the second is a performance enhancement, not a hard
// requirement).
var calculateClientHash = function () {
var hash = crypto.createHash('sha1');
hash.update(JSON.stringify(__meteor_runtime_config__), 'utf8');
_.each(WebApp.clientProgram.manifest, function (resource) {
if (resource.where === 'client' || resource.where === 'internal') {
hash.update(resource.hash);
}
});
return hash.digest('hex');
};
// We need to calculate the client hash after all packages have loaded
// to give them a chance to populate __meteor_runtime_config__.
//
// Calculating the hash during startup means that packages can only
// populate __meteor_runtime_config__ during load, not during startup.
//
// Calculating instead it at the beginning of main after all startup
// hooks had run would allow packages to also populate
// __meteor_runtime_config__ during startup, but that's too late for
// autoupdate because it needs to have the client hash at startup to
// insert the auto update version itself into
// __meteor_runtime_config__ to get it to the client.
//
// An alternative would be to give autoupdate a "post-start,
// pre-listen" hook to allow it to insert the auto update version at
// the right moment.
Meteor.startup(function () {
WebApp.clientHash = calculateClientHash();
});
var runWebAppServer = function () {
var shuttingDown = false;
// read the control for the client we'll be serving up