diff --git a/History.md b/History.md index c9cdbb3fb1..be3919064f 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,11 @@ ## vNEXT +* Rework hot code push. The new `autoupdate` package drives automatic + reloads on update using standard DDP messages instead of a hardcoded + message at DDP startup. Now the hot code push only triggers when + client code changes; server only code changes will not cause the page + to reload. + * Bundler failures cause non-zero exit code in `meteor run`. #1515 * Fix `meteor run` with settings files containing non-ASCII characters. #1497 @@ -20,7 +26,7 @@ * Upgraded dependencies: * SockJS server from 0.3.7 to 0.3.8 -Patches contributed by GitHub users mcbain, rzymek. +Patches contributed by GitHub users awwx, mcbain, rzymek. ## v0.6.6.3 diff --git a/packages/appcache/appcache-server.js b/packages/appcache/appcache-server.js index acd7006012..936be30359 100644 --- a/packages/appcache/appcache-server.js +++ b/packages/appcache/appcache-server.js @@ -84,30 +84,32 @@ 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 we have to ensure that if any static client resources change, - // something changes in the manifest file. We compute a hash of - // everything that gets delivered to the client during the initial - // web page load, and include that hash as a comment in the app - // manifest. That way if anything changes, the comment changes, and - // the browser will reload resources. + // So to ensure that the client updates if client resources change, + // include a hash of client resources in the manifest. - 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); - } - }); - var digest = hash.digest('hex'); + manifest += "# " + WebApp.clientHash + "\n"; - var manifest = "CACHE MANIFEST\n\n"; - manifest += '# ' + digest + "\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. + if (Package.autoupdate) { + var version = Package.autoupdate.AutoUpdate.autoUpdateVersion; + if (version !== WebApp.clientHash) + manifest += "# " + version + "\n"; + } + + manifest += "\n"; + manifest += "CACHE:" + "\n"; manifest += "/" + "\n"; _.each(WebApp.clientProgram.manifest, function (resource) { diff --git a/packages/appcache/package.js b/packages/appcache/package.js index 9edc1c5bb8..24fd197964 100644 --- a/packages/appcache/package.js +++ b/packages/appcache/package.js @@ -7,6 +7,7 @@ Package.on_use(function (api) { api.use('reload', 'client'); api.use('routepolicy', 'server'); api.use('underscore', 'server'); + api.use('autoupdate', 'server', {weak: true}); api.add_files('appcache-client.js', 'client'); api.add_files('appcache-server.js', 'server'); }); diff --git a/packages/autoupdate/.gitignore b/packages/autoupdate/.gitignore new file mode 100644 index 0000000000..677a6fc263 --- /dev/null +++ b/packages/autoupdate/.gitignore @@ -0,0 +1 @@ +.build* diff --git a/packages/autoupdate/QA.md b/packages/autoupdate/QA.md new file mode 100644 index 0000000000..189353ec32 --- /dev/null +++ b/packages/autoupdate/QA.md @@ -0,0 +1,114 @@ +# QA Notes + +## Hot Code Push Reload + +Run the leaderboard example, and click on one of the names. Make a +change to the leaderboard.html file, see the client reload, and see +that the name is still selected. + + +## AUTOUPDATE_VERSION + +Set the `AUTOUPDATE_VERSION` environment variable when running the +application: + + $ AUTOUPDATE_VERSION=abc meteor + +Now when you make an HTML change, it won't appear in the client +automatically. (Note the leader list flickers when the server +subscription restarts, but that's not a window reload). + +Conversely, you can force a client reload (even without making any +client code changes) by restarting the server with a new value for +`AUTOUPDATE_VERSION`. + + +## No Client Reload on Server-only Change + +Revert previous changes and run the example without setting +AUTOUPDATE_VERSION. + +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 +won't actually be reloading. + +In the browser console, assign a variable such as `a = true` so that +you can easily verify that the client hasn't reloaded. + +In the leaderboard example directory, create the `server` directory +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. + + $ meteor remove standard-app-packages + $ meteor add meteor webapp logging deps session livedata + $ meteor add mongo-livedata templating handlebars check underscore + $ meteor add jquery random ejson autoupdate + +Add to leaderboard.js: + + Template.leaderboard.available = AutoUpdate.newClientAvailable; + +And add `{{available}}` to the leaderboard template in +leaderboard.html. + +Initially you'll see `false`, and then when you make a change to the +leaderboard HTML you'll see the variable change to `true`. (You won't +see the new HTML on the client because you disabled reload). + +Amusingly, you can undo the addition you made to the HTML and the "new +client available" variable will go back to `false` (you now don't have +client code available on the server different than what's running in +the browser), because by default the client version is based on a hash +of the client files. + + +## DDP Version Negotiation Failure + +A quick way to test DDP version negotiation failure is to force the +client to use the wrong DDP version. At the top of +livedata_connection.js: + + var Connection = function (url, options) { + var self = this; + + options.supportedDDPVersions = ['abc']; + +You will see the client reload (in the hope that new client code will +be available that can successfully negotiation the DDP version). Each +reload takes longer than the one before, using an exponential backoff. + +If you remove the `options.supportedDDPVersions` line and allow the +client to connect (or manually reload the browser page so you don't +have to wait), this will reset the exponential backoff counter. + +You can verify the counter was reset by adding the line back in a +second time, and you'll see the reload cycle start over again with +first reloading quickly, and then again taking longer between tries. diff --git a/packages/autoupdate/autoupdate_client.js b/packages/autoupdate/autoupdate_client.js new file mode 100644 index 0000000000..2216e80c3f --- /dev/null +++ b/packages/autoupdate/autoupdate_client.js @@ -0,0 +1,61 @@ +// Subscribe to the `meteor_autoupdate_clientVersions` collection, +// which contains the set of acceptable client versions. +// +// A "hard code push" occurs when the current client version is not in +// the set of acceptable client versions (or the server updates the +// collection, and the current 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 current 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 there is 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. +var autoUpdateVersion = __meteor_runtime_config__.autoUpdateVersion; + + +// The collection of acceptable client versions. +var ClientVersions = new Meteor.Collection("meteor_autoupdate_clientVersions"); + + +AutoUpdate = {}; + +AutoUpdate.newClientAvailable = function () { + return !! ClientVersions.findOne( + {$and: [ + {current: true}, + {_id: {$ne: autoUpdateVersion}} + ]} + ); +}; + + +Meteor.subscribe("meteor_autoupdate_clientVersions", { + onError: function (error) { + Meteor._debug("autoupdate subscription failed:", error); + }, + onReady: function () { + if (Package.reload) { + Deps.autorun(function (computation) { + if (ClientVersions.findOne({current: true}) && + (! ClientVersions.findOne({_id: autoUpdateVersion}))) { + computation.stop(); + Package.reload.Reload._reload(); + } + }); + } + } +}); diff --git a/packages/autoupdate/autoupdate_server.js b/packages/autoupdate/autoupdate_server.js new file mode 100644 index 0000000000..1d0031e76d --- /dev/null +++ b/packages/autoupdate/autoupdate_server.js @@ -0,0 +1,74 @@ +// Publish the current client version 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. +// +// By default the current client version is identified by a hash of +// the client resources seen by the browser (the HTML, CSS, code, and +// static files in the `public` directory). +// +// 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. +// +// For backwards compatibility, SERVER_ID can be used instead of +// AUTOUPDATE_VERSION. +// +// The server publishes a `meteor_autoupdate_clientVersions` +// collection. The contract of this collection is that each document +// in the collection represents an acceptable client version, with the +// `_id` field of the document set to the client id. +// +// An "unacceptable" client version, for example, might be a version +// of the client code which has a severe UI bug, or is incompatible +// with the server. An "acceptable" client version could be one that +// is older than the latest client code available on the server but +// still works. +// +// One of the published documents in the collection will have its +// `current` field set to `true`. This is the version of the client +// code that the browser will receive from the server if it reloads. +// +// In this implementation only one document is published, the current +// client version. Developers can easily experiment with different +// versioning and updating models by forking this package. + +AutoUpdate = {}; + +// 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. + +AutoUpdate.autoUpdateVersion = null; + +Meteor.startup(function () { + if (AutoUpdate.autoUpdateVersion === null) + AutoUpdate.autoUpdateVersion = + process.env.AUTOUPDATE_VERSION || + process.env.SERVER_ID || + WebApp.clientHash; + + // Make autoUpdateVersion available on the client. + __meteor_runtime_config__.autoUpdateVersion = AutoUpdate.autoUpdateVersion; +}); + + +Meteor.publish( + "meteor_autoupdate_clientVersions", + function () { + var self = this; + // 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} +); diff --git a/packages/autoupdate/package.js b/packages/autoupdate/package.js new file mode 100644 index 0000000000..c7f04a279c --- /dev/null +++ b/packages/autoupdate/package.js @@ -0,0 +1,13 @@ +Package.describe({ + summary: "Update the client when new client code is available" +}); + +Package.on_use(function (api) { + api.use('webapp', 'server'); + api.use(['livedata', 'mongo-livedata'], ['client', 'server']); + api.use('reload', 'client', {weak: true}); + + api.export('AutoUpdate'); + api.add_files('autoupdate_server.js', 'server'); + api.add_files('autoupdate_client.js', 'client'); +}); diff --git a/packages/livedata/DDP.md b/packages/livedata/DDP.md index 8f88889a5d..655f1ca105 100644 --- a/packages/livedata/DDP.md +++ b/packages/livedata/DDP.md @@ -37,11 +37,11 @@ depending on message type. ### Procedure: -The server may send an initial message which is a JSON object lacking a `msg` -key. If so, the client should ignore it. The client does not have to wait for -this message. (This message is used to help implement hot code reload over our -SockJS transport. It is currently sent over websockets as well, but probably -should not be.) +The server may send an initial message which is a JSON object lacking +a `msg` key. If so, the client should ignore it. The client does not +have to wait for this message. (The message was once used to help +implement Meteor's hot code reload feature; it is now only included to +force old clients to update). * The client sends a `connect` message. * If the server is willing to speak the `version` of the protocol specified in diff --git a/packages/livedata/client_convenience.js b/packages/livedata/client_convenience.js index b6992d1ac7..a95fbc17dd 100644 --- a/packages/livedata/client_convenience.js +++ b/packages/livedata/client_convenience.js @@ -11,8 +11,28 @@ if (Meteor.isClient) { if (__meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL) ddpUrl = __meteor_runtime_config__.DDP_DEFAULT_CONNECTION_URL; } + + var retry = new Retry(); + + var onDDPVersionNegotiationFailure = function (description) { + Meteor._debug(description); + if (Package.reload) { + var migrationData = Package.reload.Reload._migrationData('livedata') || {}; + var failures = migrationData.DDPVersionNegotiationFailures || 0; + ++failures; + Package.reload.Reload._onMigrate('livedata', function () { + return [true, {DDPVersionNegotiationFailures: failures}]; + }); + retry.retryLater(failures, function () { + Package.reload.Reload._reload(); + }); + } + }; + Meteor.connection = - DDP.connect(ddpUrl, true /* restart_on_update */); + DDP.connect(ddpUrl, { + onDDPVersionNegotiationFailure: onDDPVersionNegotiationFailure + }); // Proxy the public methods of Meteor.connection so they can // be called directly on Meteor. diff --git a/packages/livedata/livedata_connection.js b/packages/livedata/livedata_connection.js index 04fb380f90..15b37d1b96 100644 --- a/packages/livedata/livedata_connection.js +++ b/packages/livedata/livedata_connection.js @@ -7,20 +7,18 @@ if (Meteor.isServer) { // @param url {String|Object} URL to Meteor app, // or an object as a test hook (see code) // Options: -// reloadOnUpdate: should we try to reload when the server says -// there's new code available? // reloadWithOutstanding: is it OK to reload if there are outstanding methods? +// onDDPNegotiationVersionFailure: callback when version negotiation fails. var Connection = function (url, options) { var self = this; options = _.extend({ - reloadOnUpdate: false, - // The rest of these options are only for testing. - reloadWithOutstanding: false, - supportedDDPVersions: SUPPORTED_DDP_VERSIONS, - onConnectionFailure: function (reason) { - Meteor._debug("Failed DDP connection: " + reason); + onConnected: function () {}, + onDDPVersionNegotiationFailure: function (description) { + Meteor._debug(description); }, - onConnected: function () {} + // These options are only for testing. + reloadWithOutstanding: false, + supportedDDPVersions: SUPPORTED_DDP_VERSIONS }, options); // If set, called when we reconnect, queuing method calls _before_ the @@ -162,7 +160,7 @@ var Connection = function (url, options) { // Block auto-reload while we're waiting for method responses. if (Meteor.isClient && Package.reload && !options.reloadWithOutstanding) { - Reload._onMigrate(function (retry) { + Package.reload.Reload._onMigrate(function (retry) { if (!self._readyToMigrate()) { if (self._retryMigrate) throw new Error("Two migrations in progress?"); @@ -183,7 +181,11 @@ var Connection = function (url, options) { } if (msg === null || !msg.msg) { - Meteor._debug("discarding invalid livedata message", msg); + // DEPRECATED. ignore the old welcome message for back compat. + // Remove this 'if' once the server stops sending welcome messages + // (stream_server.js). + if (! (msg && msg.server_id)) + Meteor._debug("discarding invalid livedata message", msg); return; } @@ -197,10 +199,10 @@ var Connection = function (url, options) { self._versionSuggestion = msg.version; self._stream.reconnect({_force: true}); } else { - var error = - "Version negotiation failed; server requested version " + msg.version; - self._stream.disconnect({_permanent: true, _error: error}); - options.onConnectionFailure(error); + var description = + "DDP version negotiation failed; server requested version " + msg.version; + self._stream.disconnect({_permanent: true, _error: description}); + options.onDDPVersionNegotiationFailure(description); } } else if (_.include(['added', 'changed', 'removed', 'ready', 'updated'], msg.msg)) @@ -277,17 +279,6 @@ var Connection = function (url, options) { self._stream.on('message', onMessage); self._stream.on('reset', onReset); } - - - if (Meteor.isClient && Package.reload && options.reloadOnUpdate) { - self._stream.on('update_available', function () { - // Start trying to migrate to a new version. Until all packages - // signal that they're ready for a migration, the app will - // continue running normally. - Reload._reload(); - }); - } - }; // A MethodInvoker manages sending a method to the server and calling the user's @@ -800,8 +791,16 @@ _.extend(Connection.prototype, { _unsubscribeAll: function () { var self = this; _.each(_.clone(self._subscriptions), function (sub, id) { - self._send({msg: 'unsub', id: id}); - delete self._subscriptions[id]; + // Avoid killing the autoupdate subscription so that developers + // still get hot code pushes when writing tests. + // + // XXX it's a hack to encode knowledge about autoupdate here, + // but it doesn't seem worth it yet to have a special API for + // subscriptions to preserve after unit tests. + if (sub.name !== 'meteor_autoupdate_clientVersions') { + self._send({msg: 'unsub', id: id}); + delete self._subscriptions[id]; + } }); }, @@ -1392,9 +1391,8 @@ LivedataTest.Connection = Connection; // "/", // "ddp+sockjs://ddp--****-foo.meteor.com/sockjs" // -DDP.connect = function (url, _reloadOnUpdate) { - var ret = new Connection( - url, {reloadOnUpdate: _reloadOnUpdate}); +DDP.connect = function (url, options) { + var ret = new Connection(url, options); allConnections.push(ret); // hack. see below. return ret; }; diff --git a/packages/livedata/livedata_connection_tests.js b/packages/livedata/livedata_connection_tests.js index 119a78792d..b37e3d97a7 100644 --- a/packages/livedata/livedata_connection_tests.js +++ b/packages/livedata/livedata_connection_tests.js @@ -1335,7 +1335,7 @@ Tinytest.addAsync("livedata connection - version negotiation requires renegotiat var connection = new LivedataTest.Connection(getSelfConnectionUrl(), { reloadWithOutstanding: true, supportedDDPVersions: ["garbled", LivedataTest.SUPPORTED_DDP_VERSIONS[0]], - onConnectionFailure: function () { test.fail(); onComplete(); }, + onDDPVersionNegotiationFailure: function () { test.fail(); onComplete(); }, onConnected: function () { test.equal(connection._version, LivedataTest.SUPPORTED_DDP_VERSIONS[0]); connection._stream.disconnect({_permanent: true}); @@ -1349,9 +1349,9 @@ Tinytest.addAsync("livedata connection - version negotiation error", var connection = new LivedataTest.Connection(getSelfConnectionUrl(), { reloadWithOutstanding: true, supportedDDPVersions: ["garbled", "more garbled"], - onConnectionFailure: function () { + onDDPVersionNegotiationFailure: function () { test.equal(connection.status().status, "failed"); - test.matches(connection.status().reason, /Version negotiation failed/); + test.matches(connection.status().reason, /DDP version negotiation failed/); test.isFalse(connection.status().connected); onComplete(); }, diff --git a/packages/livedata/package.js b/packages/livedata/package.js index 250c116f22..1de163f06d 100644 --- a/packages/livedata/package.js +++ b/packages/livedata/package.js @@ -31,8 +31,9 @@ Package.on_use(function (api) { api.export('LivedataTest', {testOnly: true}); // Transport - api.use('reload', 'client'); + api.use('reload', 'client', {weak: true}); api.add_files('common.js'); + api.add_files('retry.js', ['client', 'server']); api.add_files(['sockjs-0.3.4.js', 'stream_client_sockjs.js'], 'client'); api.add_files('stream_client_nodejs.js', 'server'); api.add_files('stream_client_common.js', ['client', 'server']); diff --git a/packages/livedata/retry.js b/packages/livedata/retry.js new file mode 100644 index 0000000000..ac155c19e0 --- /dev/null +++ b/packages/livedata/retry.js @@ -0,0 +1,65 @@ +// Retry logic with an exponential backoff. + +Retry = function (options) { + var self = this; + _.extend(self, _.defaults(_.clone(options || {}), { + // time for initial reconnect attempt. + baseTimeout: 1000, + // exponential factor to increase timeout each attempt. + exponent: 2.2, + // maximum time between reconnects. keep this intentionally + // high-ish to ensure a server can recover from a failure caused + // by load + maxTimeout: 5 * 60000, // 5 minutes + // time to wait for the first 2 retries. this helps page reload + // speed during dev mode restarts, but doesn't hurt prod too + // much (due to CONNECT_TIMEOUT) + minTimeout: 10, + // how many times to try to reconnect 'instantly' + minCount: 2, + // fuzz factor to randomize reconnect times by. avoid reconnect + // storms. + fuzz: 0.5 // +- 25% + })); + self.retryTimer = null; +}; + +_.extend(Retry.prototype, { + + // Reset a pending retry, if any. + clear: function () { + var self = this; + if (self.retryTimer) + clearTimeout(self.retryTimer); + self.retryTimer = null; + }, + + // Calculate how long to wait in milliseconds to retry, based on the + // `count` of which retry this is. + _timeout: function (count) { + var self = this; + + if (count < self.minCount) + return self.minTimeout; + + var timeout = Math.min( + self.maxTimeout, + self.baseTimeout * Math.pow(self.exponent, count)); + // fuzz the timeout randomly, to avoid reconnect storms when a + // server goes down. + timeout = timeout * ((Random.fraction() * self.fuzz) + + (1 - self.fuzz/2)); + return timeout; + }, + + // Call `fn` after a delay, based on the `count` of which retry this is. + retryLater: function (count, fn) { + var self = this; + var timeout = self._timeout(count); + if (self.retryTimer) + clearTimeout(self.retryTimer); + self.retryTimer = setTimeout(fn, timeout); + return timeout; + } + +}); diff --git a/packages/livedata/stream_client_common.js b/packages/livedata/stream_client_common.js index 817eb211d3..47f003cc62 100644 --- a/packages/livedata/stream_client_common.js +++ b/packages/livedata/stream_client_common.js @@ -77,7 +77,7 @@ _.extend(LivedataTest.ClientStream.prototype, { on: function (name, callback) { var self = this; - if (name !== 'message' && name !== 'reset' && name !== 'update_available') + if (name !== 'message' && name !== 'reset') throw new Error("unknown event type: " + name); if (!self.eventCallbacks[name]) @@ -94,27 +94,6 @@ _.extend(LivedataTest.ClientStream.prototype, { // failed. self.CONNECT_TIMEOUT = 10000; - - // time for initial reconnect attempt. - self.RETRY_BASE_TIMEOUT = 1000; - // exponential factor to increase timeout each attempt. - self.RETRY_EXPONENT = 2.2; - // maximum time between reconnects. keep this intentionally - // high-ish to ensure a server can recover from a failure caused - // by load - self.RETRY_MAX_TIMEOUT = 5 * 60000; // 5 minutes - // time to wait for the first 2 retries. this helps page reload - // speed during dev mode restarts, but doesn't hurt prod too - // much (due to CONNECT_TIMEOUT) - self.RETRY_MIN_TIMEOUT = 10; - // how many times to try to reconnect 'instantly' - self.RETRY_MIN_COUNT = 2; - // fuzz factor to randomize reconnect times by. avoid reconnect - // storms. - self.RETRY_FUZZ = 0.5; // +- 25% - - - self.eventCallbacks = {}; // name -> [callback] self._forcedToDisconnect = false; @@ -134,7 +113,7 @@ _.extend(LivedataTest.ClientStream.prototype, { }; //// Retry logic - self.retryTimer = null; + self._retry = new Retry; self.connectionTimer = null; }, @@ -161,9 +140,7 @@ _.extend(LivedataTest.ClientStream.prototype, { self._lostConnection(); } - if (self.retryTimer) - clearTimeout(self.retryTimer); - self.retryTimer = null; + self._retry.clear(); self.currentStatus.retryCount -= 1; // don't count manual retries self._retryNow(); }, @@ -186,10 +163,7 @@ _.extend(LivedataTest.ClientStream.prototype, { } self._cleanup(); - if (self.retryTimer) { - clearTimeout(self.retryTimer); - self.retryTimer = null; - } + self._retry.clear(); self.currentStatus = { status: (options._permanent ? "failed" : "offline"), @@ -210,22 +184,6 @@ _.extend(LivedataTest.ClientStream.prototype, { self._retryLater(); // sets status. no need to do it here. }, - _retryTimeout: function (count) { - var self = this; - - if (count < self.RETRY_MIN_COUNT) - return self.RETRY_MIN_TIMEOUT; - - var timeout = Math.min( - self.RETRY_MAX_TIMEOUT, - self.RETRY_BASE_TIMEOUT * Math.pow(self.RETRY_EXPONENT, count)); - // fuzz the timeout randomly, to avoid reconnect storms when a - // server goes down. - timeout = timeout * ((Random.fraction() * self.RETRY_FUZZ) + - (1 - self.RETRY_FUZZ/2)); - return timeout; - }, - // fired when we detect that we've gone online. try to reconnect // immediately. _online: function () { @@ -237,10 +195,10 @@ _.extend(LivedataTest.ClientStream.prototype, { _retryLater: function () { var self = this; - var timeout = self._retryTimeout(self.currentStatus.retryCount); - if (self.retryTimer) - clearTimeout(self.retryTimer); - self.retryTimer = setTimeout(_.bind(self._retryNow, self), timeout); + var timeout = self._retry.retryLater( + self.currentStatus.retryCount, + _.bind(self._retryNow, self) + ); self.currentStatus.status = "waiting"; self.currentStatus.connected = false; diff --git a/packages/livedata/stream_client_nodejs.js b/packages/livedata/stream_client_nodejs.js index 0b79754aa3..1a833c0e34 100644 --- a/packages/livedata/stream_client_nodejs.js +++ b/packages/livedata/stream_client_nodejs.js @@ -42,7 +42,6 @@ LivedataTest.ClientStream = function (endpoint) { self._initCommon(); - self.expectingWelcome = false; //// Kickoff! self._launchConnection(); }; @@ -106,19 +105,10 @@ _.extend(LivedataTest.ClientStream.prototype, { self._lostConnection(); }); - self.expectingWelcome = true; connection.on('message', function (message) { if (self.currentConnection !== this) return; // old connection still emitting messages - if (self.expectingWelcome) { - // Discard the first message that comes across the - // connection. It is the hot code push version identifier and - // is not actually part of DDP. - self.expectingWelcome = false; - return; - } - if (message.type === "utf8") // ignore binary frames _.each(self.eventCallbacks.message, function (callback) { callback(message.utf8Data); diff --git a/packages/livedata/stream_client_sockjs.js b/packages/livedata/stream_client_sockjs.js index 8605e6275c..1edf9ea7d3 100644 --- a/packages/livedata/stream_client_sockjs.js +++ b/packages/livedata/stream_client_sockjs.js @@ -20,8 +20,6 @@ LivedataTest.ClientStream = function (url) { self.rawUrl = url; self.socket = null; - self.sent_update_available = false; - self.heartbeatTimer = null; // Listen to global 'online' event if we are running in a browser. @@ -52,7 +50,7 @@ _.extend(LivedataTest.ClientStream.prototype, { self.rawUrl = url; }, - _connected: function (welcome_message) { + _connected: function () { var self = this; if (self.connectionTimer) { @@ -65,24 +63,6 @@ _.extend(LivedataTest.ClientStream.prototype, { return; } - // inspect the welcome data and decide if we have to reload - try { - var welcome_data = JSON.parse(welcome_message); - } catch (err) { - Meteor._debug("DEBUG: malformed welcome packet", welcome_message); - } - - if (welcome_data && welcome_data.server_id) { - if (__meteor_runtime_config__.serverId && - __meteor_runtime_config__.serverId !== welcome_data.server_id && - !self.sent_update_available) { - self.sent_update_available = true; - _.each(self.eventCallbacks.update_available, - function (callback) { callback(); }); - } - } else - Meteor._debug("DEBUG: invalid welcome packet", welcome_data); - // update status self.currentStatus.status = "connected"; self.currentStatus.connected = true; @@ -171,15 +151,13 @@ _.extend(LivedataTest.ClientStream.prototype, { toSockjsUrl(self.rawUrl), undefined, { debug: false, protocols_whitelist: self._sockjsProtocolsWhitelist() }); + self.socket.onopen = function (data) { + self._connected(); + }; self.socket.onmessage = function (data) { self._heartbeat_received(); - // first message we get when we're connecting goes to _connected, - // which connects us. All subsequent messages (while connected) go to - // the callback. - if (self.currentStatus.status === "connecting") - self._connected(data.data); - else if (self.currentStatus.connected) + if (self.currentStatus.connected) _.each(self.eventCallbacks.message, function (callback) { callback(data.data); }); diff --git a/packages/livedata/stream_server.js b/packages/livedata/stream_server.js index 566dc0574b..160d11e9e8 100644 --- a/packages/livedata/stream_server.js +++ b/packages/livedata/stream_server.js @@ -1,12 +1,3 @@ -// unique id for this instantiation of the server. If this changes -// between client reconnects, the client will reload. You can set the -// environment variable "SERVER_ID" to control this. For example, if -// you want to only force a reload on major changes, you can use a -// custom serverId which you only change when something worth pushing -// to clients immediately happens. -__meteor_runtime_config__.serverId = - process.env.SERVER_ID ? process.env.SERVER_ID : Random.id(); - var pathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ""; StreamServer = function () { @@ -74,10 +65,12 @@ StreamServer = function () { }); self.open_sockets.push(socket); - - // Send a welcome message with the serverId. Client uses this to - // reload if needed. - socket.send(JSON.stringify({server_id: __meteor_runtime_config__.serverId})); + // DEPRECATED. Send the old style welcome message, which will force + // old clients to reload. Remove this once we're not concerned about + // people upgrading from a pre-0.6.7 release. Also, remove the + // clause in the client that ignores the welcome message + // (livedata_connection.js) + socket.send(JSON.stringify({server_id: "0"})); // call all our callbacks when we get a new socket. they will do the // work of setting up handlers and such for specific messages. diff --git a/packages/standard-app-packages/package.js b/packages/standard-app-packages/package.js index 147674e8e5..5510982ad2 100644 --- a/packages/standard-app-packages/package.js +++ b/packages/standard-app-packages/package.js @@ -42,4 +42,13 @@ Package.on_use(function(api) { // People like being able to clone objects. 'ejson' ]); + + // These are useful too! But you don't have to see their exports + // unless you want to. + api.use([ + // We can reload the client without messing up methods in flight. + 'reload', + // And update automatically when new client code is available! + 'autoupdate' + ], ['client', 'server']); }); diff --git a/packages/test-in-browser/autoupdate.js b/packages/test-in-browser/autoupdate.js new file mode 100644 index 0000000000..b97d27fd4d --- /dev/null +++ b/packages/test-in-browser/autoupdate.js @@ -0,0 +1,7 @@ +// autoupdate normally won't reload on server-only changes, but when +// running tests in the browser it's nice to have server changes cause +// the tests to reload. Setting the auto update version to a +// different value when the server restarts accomplishes this. + +if (Package.autoupdate) + Package.autoupdate.AutoUpdate.autoUpdateVersion = Random.id(); diff --git a/packages/test-in-browser/package.js b/packages/test-in-browser/package.js index 61eaf8980b..cfe84ca858 100644 --- a/packages/test-in-browser/package.js +++ b/packages/test-in-browser/package.js @@ -21,4 +21,8 @@ Package.on_use(function (api) { 'driver.html', 'driver.js' ], "client"); + + api.use('autoupdate', 'server', {weak: true}); + api.use('random', 'server'); + api.add_files('autoupdate.js', 'server'); }); diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index 8225fa3f2b..36f469d74d 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -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