diff --git a/packages/autoupdate/autoupdate_client.js b/packages/autoupdate/autoupdate_client.js index 45179f8ba4..70dc064f98 100644 --- a/packages/autoupdate/autoupdate_client.js +++ b/packages/autoupdate/autoupdate_client.js @@ -25,11 +25,11 @@ // 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. -var ClientVersions = new Meteor.Collection("meteor_autoupdate_clientVersions"); - +ClientVersions = new Meteor.Collection("meteor_autoupdate_clientVersions"); Autoupdate = {}; @@ -42,7 +42,7 @@ Autoupdate.newClientAvailable = function () { ); }; - +var knownToSupportCssOnLoad = false; var retry = new Retry({ // Unlike the stream reconnect use of Retry, which we want to be instant @@ -76,15 +76,72 @@ Autoupdate._retrySubscription = function () { }, onReady: function () { if (Package.reload) { - Deps.autorun(function (computation) { - if (ClientVersions.findOne({current: true}) && - (! ClientVersions.findOne({_id: autoupdateVersion}))) { - computation.stop(); - Package.reload.Reload._reload(); + var handle = ClientVersions.find().observeChanges({ + added: function (id, fields) { + var self = this; + if (fields.refreshable && id !== autoupdateVersionRefreshable) { + autoupdateVersionRefreshable = id; + + // 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 = fields.assets.allCss; + var oldLinks = []; + _.each(document.getElementsByTagName('link'), function (link) { + if (link.className === '__meteor-css__') { + oldLinks.push(link); + } + }); + + var waitUntilCssLoads = function (link, callback) { + var executeCallback = _.once(callback); + link.onload = function () { + knownToSupportCssOnLoad = true; + executeCallback(); + }; + if (! knownToSupportCssOnLoad) { + var id = Meteor.setInterval(function () { + if (link.sheet) { + executeCallback(); + Meteor.clearInterval(id); + } + }, 50); + } + }; + + var attachStylesheetLink = function (newLink) { + var removeOldLinks = _.after(newCss.length, function () { + _.each(oldLinks, function (oldLink) { + oldLink.parentNode.removeChild(oldLink); + }); + }); + + document.getElementsByTagName("head"). + item(0). + insertBefore(newLink); + + waitUntilCssLoads(newLink, function () { + Meteor.setTimeout(removeOldLinks, 200); + }); + }; + + _.each(newCss, 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 if (! fields.refreshable && id !== autoupdateVersion) { + if (handle) { + handle.stop(); + Package.reload.Reload._reload(); + } + } } }); } - } + } }); }; Autoupdate._retrySubscription(); diff --git a/packages/autoupdate/autoupdate_server.js b/packages/autoupdate/autoupdate_server.js index 9bf2a8dfd5..c8ed075a9f 100644 --- a/packages/autoupdate/autoupdate_server.js +++ b/packages/autoupdate/autoupdate_server.js @@ -37,45 +37,90 @@ Autoupdate = {}; +// The collection of acceptable client versions. +ClientVersions = new Meteor.Collection("meteor_autoupdate_clientVersions", + { connection: null }); + // 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; +Autoupdate.autoupdateVersionRefreshable = null; + +var syncQueue = new Meteor._SynchronousQueue(); +var startupVersion = null; + +// updateVersions can only be called after the server has fully loaded. +var updateVersions = function () { + syncQueue.runTask(function () { + var oldVersion = Autoupdate.autoupdateVersion; + var oldVersionRefreshable = Autoupdate.autoupdateVersionRefreshable; + + // Step 1: load the current client program on the server and update the + // hash values in __meteor_runtime_config__. + WebAppInternals.reloadClientProgram(); + + if (startupVersion === null) { + Autoupdate.autoupdateVersion = + __meteor_runtime_config__.autoupdateVersion = + process.env.AUTOUPDATE_VERSION || + process.env.SERVER_ID || // XXX COMPAT 0.6.6 + WebApp.calculateClientHashNonRefreshable(); + } + + Autoupdate.autoupdateVersionRefreshable = + __meteor_runtime_config__.autoupdateVersionRefreshable = + process.env.AUTOUPDATE_VERSION || + process.env.SERVER_ID || // XXX COMPAT 0.6.6 + WebApp.calculateClientHashRefreshable(); + + // Step 2: form the new client boilerplate which contains the updated + // assets and __meteor_runtime_config__. + WebAppInternals.generateBoilerplate(); + + if (Autoupdate.autoupdateVersion !== oldVersion) { + if (oldVersion) { + ClientVersions.remove(oldVersion); + } + + ClientVersions.insert({ + _id: Autoupdate.autoupdateVersion, + refreshable: false, + current: true, + }); + } + + if (Autoupdate.autoupdateVersionRefreshable !== oldVersionRefreshable) { + if (oldVersionRefreshable) { + ClientVersions.remove(oldVersionRefreshable); + } + ClientVersions.insert({ + _id: Autoupdate.autoupdateVersionRefreshable, + refreshable: true, + assets: WebAppInternals.refreshableAssets + }); + } + }); +}; Meteor.startup(function () { - // Allow people to override Autoupdate.autoupdateVersion before - // startup. Tests do this. - if (Autoupdate.autoupdateVersion === null) - Autoupdate.autoupdateVersion = - process.env.AUTOUPDATE_VERSION || - process.env.SERVER_ID || // XXX COMPAT 0.6.6 - WebApp.clientHash; - - // Make autoupdateVersion available on the client. - __meteor_runtime_config__.autoupdateVersion = Autoupdate.autoupdateVersion; + // Allow people to override Autoupdate.autoupdateVersion before startup. + // Tests do this. + startupVersion = Autoupdate.autoupdateVersion; + WebApp.onListening(updateVersions); }); - 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. - if (Autoupdate.autoupdateVersion) { - self.added( - "meteor_autoupdate_clientVersions", - Autoupdate.autoupdateVersion, - {current: true} - ); - self.ready(); - } else { - // huh? shouldn't happen. Just error the sub. - self.error(new Meteor.Error(500, "Autoupdate.autoupdateVersion not set")); - } + return ClientVersions.find(); }, {is_auto: true} ); + +// Listen for SIGUSR2, which signals that a client asset has changed. +process.on('SIGUSR2', Meteor.bindEnvironment(function () { + updateVersions(); +})); \ No newline at end of file diff --git a/packages/webapp/boilerplate.html b/packages/webapp/boilerplate.html index e71c29c5ba..2d255681c1 100644 --- a/packages/webapp/boilerplate.html +++ b/packages/webapp/boilerplate.html @@ -1,6 +1,6 @@ -{{#each css}} {{/each}} +{{#each css}} {{/each}} {{#if inlineScriptsAllowed}} diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index 5b2429cefd..fab745b534 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -11,6 +11,8 @@ var connect = Npm.require('connect'); var useragent = Npm.require('useragent'); var send = Npm.require('send'); +var Future = Npm.require('fibers/future'); + var SHORT_SOCKET_TIMEOUT = 5*1000; var LONG_SOCKET_TIMEOUT = 120*1000; @@ -62,6 +64,10 @@ var sha1 = function (contents) { return hash.digest('hex'); }; +var readUtf8FileSync = function (filename) { + return Future.wrap(fs.readFile)(filename, 'utf8').wait(); +}; + // #BrowserIdentification // // We have multiple places that want to identify the browser: the @@ -179,11 +185,15 @@ var appUrl = function (url) { // (but the second is a performance enhancement, not a hard // requirement). -var calculateClientHash = function () { +var calculateClientHash = function (includeFilter) { var hash = crypto.createHash('sha1'); - hash.update(JSON.stringify(__meteor_runtime_config__), 'utf8'); + // Omit the old hashed client values in the new hash. These may be + // modified in the new boilerplate. + hash.update(JSON.stringify(_.omit(__meteor_runtime_config__, + ['autoupdateVersion', 'autoupdateVersionRefreshable']), 'utf8')); _.each(WebApp.clientProgram.manifest, function (resource) { - if (resource.where === 'client' || resource.where === 'internal') { + if ((! includeFilter || includeFilter(resource.type)) && + (resource.where === 'client' || resource.where === 'internal')) { hash.update(resource.path); hash.update(resource.hash); } @@ -211,6 +221,16 @@ var calculateClientHash = function () { Meteor.startup(function () { WebApp.clientHash = calculateClientHash(); + WebApp.calculateClientHashRefreshable = function () { + return calculateClientHash(function (name) { + return name === "css"; + }); + }; + WebApp.calculateClientHashNonRefreshable = function () { + return calculateClientHash(function (name) { + return name !== "css"; + }); + }; }); @@ -237,15 +257,69 @@ WebApp._timeoutAdjustmentRequestCallback = function (req, res) { var runWebAppServer = function () { var shuttingDown = false; - // read the control for the client we'll be serving up - var clientJsonPath = path.join(__meteor_bootstrap__.serverDir, - __meteor_bootstrap__.configJson.client); - var clientDir = path.dirname(clientJsonPath); - var clientJson = JSON.parse(fs.readFileSync(clientJsonPath, 'utf8')); + var syncQueue = new Meteor._SynchronousQueue(); - if (clientJson.format !== "browser-program-pre1") - throw new Error("Unsupported format for client assets: " + - JSON.stringify(clientJson.format)); + var getItemPathname = function (itemUrl) { + return decodeURIComponent(url.parse(itemUrl).pathname); + }; + + var staticFiles; + + var clientJsonPath; + var clientDir; + var clientJson; + + WebAppInternals.reloadClientProgram = function () { + syncQueue.runTask(function() { + try { + // read the control for the client we'll be serving up + clientJsonPath = path.join(__meteor_bootstrap__.serverDir, + __meteor_bootstrap__.configJson.client); + clientDir = path.dirname(clientJsonPath); + clientJson = JSON.parse(readUtf8FileSync(clientJsonPath)); + if (clientJson.format !== "browser-program-pre1") + throw new Error("Unsupported format for client assets: " + + JSON.stringify(clientJson.format)); + + staticFiles = {}; + _.each(clientJson.manifest, function (item) { + if (item.url && item.where === "client") { + staticFiles[getItemPathname(item.url)] = { + path: item.path, + cacheable: item.cacheable, + // Link from source to its map + sourceMapUrl: item.sourceMapUrl, + type: item.type + }; + + if (item.sourceMap) { + // Serve the source map too, under the specified URL. We assume all + // source maps are cacheable. + staticFiles[getItemPathname(item.sourceMapUrl)] = { + path: item.sourceMap, + cacheable: true + }; + } + } + }); + WebApp.clientProgram = { + manifest: clientJson.manifest + // XXX do we need a "root: clientDir" field here? it used to be here but + // was unused. + }; + + // Exported for tests. + WebAppInternals.staticFiles = staticFiles; + } catch (e) { + Log.error("Error reloading the client program: " + e.message); + process.exit(1); + } + }); + }; + WebAppInternals.reloadClientProgram(); + + if (! clientJsonPath || ! clientDir || ! clientJson) + throw new Error("Client config file not parsed."); // webserver var app = connect(); @@ -285,36 +359,6 @@ var runWebAppServer = function () { // generally pretty handy.. app.use(connect.query()); - var getItemPathname = function (itemUrl) { - return decodeURIComponent(url.parse(itemUrl).pathname); - }; - - var staticFiles = {}; - _.each(clientJson.manifest, function (item) { - if (item.url && item.where === "client") { - staticFiles[getItemPathname(item.url)] = { - path: item.path, - cacheable: item.cacheable, - // Link from source to its map - sourceMapUrl: item.sourceMapUrl, - type: item.type - }; - - if (item.sourceMap) { - // Serve the source map too, under the specified URL. We assume all - // source maps are cacheable. - staticFiles[getItemPathname(item.sourceMapUrl)] = { - path: item.sourceMap, - cacheable: true - }; - } - } - }); - - // Exported for tests. - WebAppInternals.staticFiles = staticFiles; - - // Serve static files from the manifest. // This is inspired by the 'static' middleware. app.use(function (req, res, next) { @@ -480,10 +524,9 @@ var runWebAppServer = function () { return undefined; } - var htmlAttributes = getHtmlAttributes(request); - // The only thing that changes from request to request (for now) are the // HTML attributes (used by, eg, appcache), so we can memoize based on that. + var htmlAttributes = getHtmlAttributes(request); var attributeKey = JSON.stringify(htmlAttributes); if (!_.has(boilerplateByAttributes, attributeKey)) { try { @@ -568,12 +611,6 @@ var runWebAppServer = function () { connectHandlers: packageAndAppHandlers, rawConnectHandlers: rawConnectHandlers, httpServer: httpServer, - // metadata about the client program that we serve - clientProgram: { - manifest: clientJson.manifest - // XXX do we need a "root: clientDir" field here? it used to be here but - // was unused. - }, // For testing. suppressConnectErrors: function () { suppressConnectErrors = true; @@ -602,48 +639,60 @@ var runWebAppServer = function () { // '--keepalive' is a use of the option. var expectKeepalives = _.contains(argv, '--keepalive'); - boilerplateBaseData = { - css: [], - js: [], - head: '', - body: '', - inlineScriptsAllowed: WebAppInternals.inlineScriptsAllowed(), - meteorRuntimeConfig: JSON.stringify(__meteor_runtime_config__), - reloadSafetyBelt: RELOAD_SAFETYBELT, - rootUrlPathPrefix: __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '', - bundledJsCssPrefix: bundledJsCssPrefix || - __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '' - }; - - _.each(WebApp.clientProgram.manifest, function (item) { - if (item.type === 'css' && item.where === 'client') { - boilerplateBaseData.css.push({url: item.url}); - } - if (item.type === 'js' && item.where === 'client') { - boilerplateBaseData.js.push({url: item.url}); - } - if (item.type === 'head') { - boilerplateBaseData.head = fs.readFileSync( - path.join(clientDir, item.path), 'utf8'); - } - if (item.type === 'body') { - boilerplateBaseData.body = fs.readFileSync( - path.join(clientDir, item.path), 'utf8'); - } - }); - var boilerplateTemplateSource = Assets.getText("boilerplate.html"); - var boilerplateRenderCode = Spacebars.compile( - boilerplateTemplateSource, { isBody: true }); - // Note that we are actually depending on eval's local environment capture - // so that UI and HTML are visible to the eval'd code. - var boilerplateRender = eval(boilerplateRenderCode); + // Exported to allow client-side only changes to rebuild the boilerplate + // without requiring a full server restart. + WebAppInternals.generateBoilerplate = function () { + syncQueue.runTask(function() { + boilerplateBaseData = { + css: [], + js: [], + head: '', + body: '', + inlineScriptsAllowed: WebAppInternals.inlineScriptsAllowed(), + meteorRuntimeConfig: JSON.stringify(__meteor_runtime_config__), + reloadSafetyBelt: RELOAD_SAFETYBELT, + rootUrlPathPrefix: __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '', + bundledJsCssPrefix: bundledJsCssPrefix || + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '' + }; - boilerplateTemplate = UI.Component.extend({ - kind: "MainPage", - render: boilerplateRender - }); + _.each(WebApp.clientProgram.manifest, function (item) { + if (item.type === 'css' && item.where === 'client') { + boilerplateBaseData.css.push({url: item.url}); + } + if (item.type === 'js' && item.where === 'client') { + boilerplateBaseData.js.push({url: item.url}); + } + if (item.type === 'head') { + boilerplateBaseData.head = + readUtf8FileSync(path.join(clientDir, item.path)); + } + if (item.type === 'body') { + boilerplateBaseData.body = + readUtf8FileSync(path.join(clientDir, item.path)); + } + }); + + var boilerplateRenderCode = Spacebars.compile( + boilerplateTemplateSource, { isBody: true }); + + // Note that we are actually depending on eval's local environment capture + // so that UI and HTML are visible to the eval'd code. + var boilerplateRender = eval(boilerplateRenderCode); + boilerplateTemplate = UI.Component.extend({ + kind: "MainPage", + render: boilerplateRender + }); + + // Clear the memoized boilerplate cache. + boilerplateByAttributes = {}; + + WebAppInternals.refreshableAssets = { allCss: boilerplateBaseData.css }; + }); + }; + WebAppInternals.generateBoilerplate(); // only start listening after all the startup code has run. var localPort = parseInt(process.env.PORT) || 0; diff --git a/tools/bundler.js b/tools/bundler.js index 90c1a3b1dd..8bbb9bb180 100644 --- a/tools/bundler.js +++ b/tools/bundler.js @@ -1578,17 +1578,23 @@ var writeSiteArchive = function (targets, outputPath, options) { builder.writeJson('star.json', json); // Merge the WatchSet of everything that went into the bundle. - var watchSet = new watch.WatchSet(); + var clientWatchSet = new watch.WatchSet(); + var serverWatchSet = new watch.WatchSet(); var dependencySources = [builder].concat(_.values(targets)); _.each(dependencySources, function (s) { - watchSet.merge(s.getWatchSet()); + if (s instanceof ClientTarget) { + clientWatchSet.merge(s.getWatchSet()); + } else { + serverWatchSet.merge(s.getWatchSet()); + } }); // We did it! builder.complete(); return { - watchSet: watchSet, + clientWatchSet: clientWatchSet, + serverWatchSet: serverWatchSet, starManifest: json }; } catch (e) { @@ -1669,12 +1675,14 @@ exports.bundle = function (options) { " " + release.current.name : ""); var success = false; - var watchSet = new watch.WatchSet(); + var serverWatchSet = new watch.WatchSet(); + var clientWatchSet = new watch.WatchSet(); var starResult = null; + var targets = {}; + var messages = buildmessage.capture({ title: "building the application" }, function () { - var targets = {}; var controlProgram = null; var makeClientTarget = function (app) { @@ -1730,7 +1738,7 @@ exports.bundle = function (options) { // case.) var includeDefaultTargets = watch.readAndWatchFile( - watchSet, path.join(appDir, 'no-default-targets')) === null; + serverWatchSet, path.join(appDir, 'no-default-targets')) === null; if (includeDefaultTargets) { // Create a Unipackage object that represents the app @@ -1742,7 +1750,8 @@ exports.bundle = function (options) { targets.client = client; // Server - var server = makeServerTarget(app, client); + var server = options.cachedServerTarget || makeServerTarget(app, client); + server.clientTarget = client; targets.server = server; } @@ -1752,7 +1761,7 @@ exports.bundle = function (options) { var programs = []; var programsDir = project.project.getProgramsDirectory(); var programsSubdirs = project.project.getProgramsSubdirs({ - watchSet: watchSet + watchSet: serverWatchSet }); _.each(programsSubdirs, function (item) { @@ -1770,7 +1779,7 @@ exports.bundle = function (options) { // the package.js file here, though (but we do restart if it is later // added or changed). if (watch.readAndWatchFile( - watchSet, path.join(programsDir, item, 'package.js')) === null) { + serverWatchSet, path.join(programsDir, item, 'package.js')) === null) { return; } @@ -1780,7 +1789,7 @@ exports.bundle = function (options) { var attrsJsonAbsPath = path.join(programsDir, item, 'attributes.json'); var attrsJsonRelPath = path.join('programs', item, 'attributes.json'); var attrsJsonContents = watch.readAndWatchFile( - watchSet, attrsJsonAbsPath); + serverWatchSet, attrsJsonAbsPath); var attrsJson = {}; if (attrsJsonContents !== null) { @@ -1900,7 +1909,8 @@ exports.bundle = function (options) { controlProgram: controlProgram, releaseName: releaseName }); - watchSet.merge(starResult.watchSet); + serverWatchSet.merge(starResult.serverWatchSet); + clientWatchSet.merge(starResult.clientWatchSet); success = true; }); @@ -1910,8 +1920,10 @@ exports.bundle = function (options) { return { errors: success ? false : messages, - watchSet: watchSet, - starManifest: starResult && starResult.starManifest + serverWatchSet: serverWatchSet, + clientWatchSet: clientWatchSet, + starManifest: starResult && starResult.starManifest, + serverTarget: targets.server }; }; diff --git a/tools/compiler.js b/tools/compiler.js index ebcc09e905..f1e3a8ed73 100644 --- a/tools/compiler.js +++ b/tools/compiler.js @@ -390,9 +390,16 @@ var compileUnibuild = function (unipackage, inputSourceArch, packageLoader, var fileOptions = _.clone(source.fileOptions) || {}; var absPath = path.resolve(inputSourceArch.pkg.sourceRoot, relPath); var filename = path.basename(relPath); - var file = watch.readAndWatchFileWithHash(watchSet, absPath); + var sourceWatchSet = new watch.WatchSet(); + var file = watch.readAndWatchFileWithHash(sourceWatchSet, absPath); var contents = file.contents; + // Only add the source file to the WatchSet if it's actually added to + // the build. This is a hacky workaround because plugins do not register + // themselves as "client" or "server", so we need to detect whether a file + // is actually added to the client/server program. + var sourceIsWatched = false; + sources.push(relPath); if (contents === null) { @@ -563,6 +570,7 @@ var compileUnibuild = function (unipackage, inputSourceArch, packageLoader, throw new Error("'section' must be 'head' or 'body'"); if (typeof options.data !== "string") throw new Error("'data' option to appendDocument must be a string"); + sourceIsWatched = true; resources.push({ type: options.section, data: new Buffer(options.data, 'utf8') @@ -574,8 +582,10 @@ var compileUnibuild = function (unipackage, inputSourceArch, packageLoader, "browser targets"); if (typeof options.data !== "string") throw new Error("'data' option to addStylesheet must be a string"); + sourceIsWatched = true; resources.push({ type: "css", + refreshable: true, data: new Buffer(options.data, 'utf8'), servePath: path.join(inputSourceArch.pkg.serveRoot, options.path), sourceMap: options.sourceMap @@ -588,6 +598,7 @@ var compileUnibuild = function (unipackage, inputSourceArch, packageLoader, throw new Error("'sourcePath' option must be supplied to addJavaScript. Consider passing inputPath."); if (options.bare && ! archinfo.matches(inputSourceArch.arch, "browser")) throw new Error("'bare' option may only be used for browser targets"); + sourceIsWatched = true; js.push({ source: options.data, sourcePath: options.sourcePath, @@ -599,6 +610,7 @@ var compileUnibuild = function (unipackage, inputSourceArch, packageLoader, addAsset: function (options) { if (! (options.data instanceof Buffer)) throw new Error("'data' option to addAsset must be a Buffer"); + sourceIsWatched = true; addAsset(options.data, options.path); }, error: function (options) { @@ -620,6 +632,10 @@ var compileUnibuild = function (unipackage, inputSourceArch, packageLoader, // Recover by ignoring this source file (as best we can -- the // handler might already have emitted resources) } + + if (sourceIsWatched) { + watchSet.merge(sourceWatchSet); + } }); // *** Run Phase 1 link diff --git a/tools/help.txt b/tools/help.txt index e7cee89514..e7da3d17a9 100644 --- a/tools/help.txt +++ b/tools/help.txt @@ -286,6 +286,7 @@ Options: Run tests of the 'meteor' tool. Usage: meteor self-test [pattern] [--changed] [--slow] [--force-online] [--history n] + [--browserstack] Runs internal tests. Exits with status 0 on success. diff --git a/tools/run-app.js b/tools/run-app.js index 82c7bcf90e..5fa50d398b 100644 --- a/tools/run-app.js +++ b/tools/run-app.js @@ -314,7 +314,8 @@ _.extend(AppProcess.prototype, { // - bundleResult: for runs in which bundling happened (all except // 'wrong-release', 'conflicting-versions' and possibly 'stopped'), the return // value from bundler.bundle(), which contains such interesting things as the -// build errors and a watchset describing the source files of the app. +// build errors and a watchset describing the server source files and client +// source files of the app. var AppRunner = function (appDir, options) { var self = this; @@ -410,19 +411,38 @@ _.extend(AppRunner.prototype, { } // Bundle up the app - if (! self.firstRun) - packageCache.packageCache.refresh(true); // pick up changes to packages - var bundlePath = path.join(self.appDir, '.meteor', 'local', 'build'); if (self.recordPackageUsage) stats.recordPackages(self.appDir); - var bundleResult = bundler.bundle({ - outputPath: bundlePath, - includeNodeModulesSymlink: true, - buildOptions: self.buildOptions - }); - var watchSet = bundleResult.watchSet; + // Cache the server target because the server will not change inside + // a single invocation of _runOnce(). + var cachedServerTarget = null; + var bundleApp = function () { + if (! self.firstRun) + packageCache.packageCache.refresh(true); // pick up changes to packages + + var bundle = bundler.bundle({ + outputPath: bundlePath, + includeNodeModulesSymlink: true, + buildOptions: self.buildOptions, + cachedServerTarget: cachedServerTarget + }); + + cachedServerTarget = bundle.serverTarget; + return bundle; + }; + + var bundleResult = bundleApp(); + + if (bundleResult.errors) { + return { + outcome: 'bundle-fail', + bundleResult: bundleResult + }; + } + + var serverWatchSet = bundleResult.serverWatchSet; // Read the settings file, if any var settings = null; @@ -438,7 +458,7 @@ _.extend(AppRunner.prototype, { // HACK: merge the watchset and messages from reading the settings // file into those from the build. This works fine but it sort of // messy. Maybe clean it up sometime. - watchSet.merge(settingsWatchSet); + serverWatchSet.merge(settingsWatchSet); if (settingsMessages.hasMessages()) { if (! bundleResult.errors) bundleResult.errors = settingsMessages; @@ -448,15 +468,7 @@ _.extend(AppRunner.prototype, { // HACK: Also make sure we notice when somebody adds a package to // the app packages dir that may override a catalog package. - catalog.complete.watchLocalPackageDirs(watchSet); - - // Were there errors? - if (bundleResult.errors) { - return { - outcome: 'bundle-fail', - bundleResult: bundleResult - }; - } + catalog.complete.watchLocalPackageDirs(serverWatchSet); // Atomically (1) see if we've been stop()'d, (2) if not, create a // future that can be used to stop() us once we start running. @@ -464,7 +476,7 @@ _.extend(AppRunner.prototype, { return { outcome: 'stopped', bundleResult: bundleResult }; if (self.runFuture) throw new Error("already have future?"); - var runFuture = self.runFuture = new Future; + self.runFuture = new Future; // Run the program var appProcess = new AppProcess({ @@ -495,13 +507,15 @@ _.extend(AppRunner.prototype, { appProcess.start(); // Start watching for changes for files if requested. There's no - // hurry to do this, since watchSet contains a snapshot of the + // hurry to do this, since clientWatchSet contains a snapshot of the // state of the world at the time of bundling, in the form of // hashes and lists of matching files in each directory. - var watcher; + var serverWatcher; + var clientWatcher; + if (self.watchForChanges) { - watcher = new watch.Watcher({ - watchSet: watchSet, + serverWatcher = new watch.Watcher({ + watchSet: serverWatchSet, onChange: function () { self._runFutureReturn({ outcome: 'changed', @@ -511,15 +525,57 @@ _.extend(AppRunner.prototype, { }); } + var setupClientWatcher = function () { + clientWatcher && clientWatcher.stop(); + clientWatcher = new watch.Watcher({ + watchSet: bundleResult.clientWatchSet, + onChange: function () { + var outcome = watch.isUpToDate(serverWatchSet) + ? 'changed-refreshable' // only a client asset has changed + : 'changed'; // both a client and server asset changed + self._runFutureReturn({ + outcome: outcome, + bundleResult: bundleResult + }); + } + }); + }; + if (self.watchForChanges) { + setupClientWatcher(); + } + // Wait for either the process to exit, or (if watchForChanges) a // source file to change. Or, for stop() to be called. - var ret = runFuture.wait(); + var ret = self.runFuture.wait(); + + while (ret.outcome === 'changed-refreshable') { + // We stay in this loop as long as only refreshable assets have changed. + // When ret.refreshable becomes false, we restart the server. + bundleResult = bundleApp(); + if (bundleResult.errors) { + return { + outcome: 'bundle-fail', + bundleResult: bundleResult + }; + } + + // Establish a watcher on the new files. + setupClientWatcher(); + + // Notify the server that new client assets have been added to the build. + process.kill(appProcess.proc.pid, 'SIGUSR2'); + runLog.logClientRestart(); + + self.runFuture = new Future; + ret = self.runFuture.wait(); + } self.runFuture = null; self.proxy.setMode("hold"); appProcess.stop(); - if (watcher) - watcher.stop(); + + serverWatcher && serverWatcher.stop(); + clientWatcher && clientWatcher.stop(); return ret; }, @@ -614,8 +670,12 @@ _.extend(AppRunner.prototype, { if (self.watchForChanges) { self.watchFuture = new Future; + + var watchSet = new watch.WatchSet(); + watchSet.merge(runResult.bundleResult.serverWatchSet); + watchSet.merge(runResult.bundleResult.clientWatchSet); var watcher = new watch.Watcher({ - watchSet: runResult.bundleResult.watchSet, + watchSet: watchSet, onChange: function () { self._watchFutureReturn(); } diff --git a/tools/run-log.js b/tools/run-log.js index 32a06094da..4db60f77ec 100644 --- a/tools/run-log.js +++ b/tools/run-log.js @@ -41,6 +41,7 @@ var RunLog = function () { // message, and the value will be the number of consecutive such // messages that have been logged with no other intervening messages self.consecutiveRestartMessages = null; + self.consecutiveClientRestartMessages = null; // If non-null, the last thing that was logged was a temporary // message (with a carriage return but no newline), and this is its @@ -66,6 +67,11 @@ _.extend(RunLog.prototype, { process.stdout.write("\n"); } + if (self.consecutiveClientRestartMessages) { + self.consecutiveClientRestartMessages = null; + process.stdout.write("\n"); + } + if (self.temporaryMessageLength) { var spaces = new Array(self.temporaryMessageLength + 1).join(' '); process.stdout.write(spaces + '\r'); @@ -155,6 +161,33 @@ _.extend(RunLog.prototype, { }); }, + logClientRestart: function () { + var self = this; + + if (self.consecutiveClientRestartMessages) { + // replace old message in place. this assumes that the new restart message + // is not shorter than the old one. + process.stdout.write("\r"); + self.messages.pop(); + self.consecutiveClientRestartMessages ++; + } else { + self._clearSpecial(); + self.consecutiveClientRestartMessages = 1; + } + + var message = "=> Client modified -- refreshing"; + if (self.consecutiveClientRestartMessages > 1) + message += " (x" + self.consecutiveClientRestartMessages + ")"; + // no newline, so that we can overwrite it if we get another + // restart message right after this one + process.stdout.write(message); + + self._record({ + time: new Date, + message: message + }); + }, + finish: function () { var self = this; @@ -176,8 +209,8 @@ _.extend(RunLog.prototype, { // object you get with require('./run-log.js'). var runLogInstance = new RunLog; _.each( - ['log', 'logTemporary', 'logRestart', 'logAppOutput', 'setRawLogs', - 'finish', 'clearLog', 'getLog'], + ['log', 'logTemporary', 'logRestart', 'logClientRestart', 'logAppOutput', + 'setRawLogs', 'finish', 'clearLog', 'getLog'], function (method) { exports[method] = _.bind(runLogInstance[method], runLogInstance); }); diff --git a/tools/selftest.js b/tools/selftest.js index a38e8ef0a9..0a67ed0f71 100644 --- a/tools/selftest.js +++ b/tools/selftest.js @@ -408,6 +408,7 @@ _.extend(Sandbox.prototype, { " to run against clients." ); } _.each(self.clients, function (client) { + console.log("testing with " + client.name + "..."); f(new Run(self.execPath, { sandbox: self, args: [], @@ -662,6 +663,7 @@ var PhantomClient = function (options) { var self = this; Client.apply(this, arguments); + self.name = "phantomjs"; self.process = null; }; @@ -677,11 +679,7 @@ _.extend(PhantomClient.prototype, { '/bin/bash', ['-c', ("exec " + phantomPath + " --load-images=no /dev/stdin <<'END'\n" + - phantomScript + "END\n")], function (err, stdout, stderr) { - if (stderr.match(/not found/)) { - console.log("ERROR: phantomjs not installed properly."); - } - }); + phantomScript + "END\n")]); }, stop: function() { var self = this; @@ -697,6 +695,7 @@ var BrowserStackClient = function (options) { var self = this; Client.apply(this, arguments); + self.name = "BrowserStack"; self.tunnelProcess = null; self.driver = null; }; diff --git a/tools/tests/apps/css-injection-test/css-injection-test.js b/tools/tests/apps/css-injection-test/css-injection-test.js index 6160e04bc8..3aaecf916e 100644 --- a/tools/tests/apps/css-injection-test/css-injection-test.js +++ b/tools/tests/apps/css-injection-test/css-injection-test.js @@ -9,17 +9,29 @@ if (Meteor.isClient) { }).join('\n')); }; - Meteor.call("clientLoad"); - var numCssChanges = 0; - var oldCss = allCss(); - Meteor.call("newStylesheet", numCssChanges, oldCss); - setInterval(function () { - var newCss = allCss(); - if (oldCss !== newCss) { - oldCss = newCss; - Meteor.call("newStylesheet", ++numCssChanges, newCss); - } - }, 500); + Meteor.startup(function () { + Meteor.call("clientLoad"); + var numCssChanges = 0; + var oldCss = allCss(); + Meteor.call("newStylesheet", numCssChanges, oldCss); + var callingServer = false; + Meteor.setInterval(function () { + if (callingServer) + return; + + var newCss = allCss(); + if (oldCss !== newCss) { + callingServer = true; + // give the client some time to load the new css + Meteor.setTimeout(function () { + var newCss = allCss(); + oldCss = newCss; + Meteor.call("newStylesheet", ++numCssChanges, newCss); + callingServer = false; + }, 1000); + } + }, 500); + }); } if (Meteor.isServer) { @@ -30,7 +42,7 @@ if (Meteor.isServer) { newStylesheet: function (numCssChanges, cssText) { console.log("numCssChanges: " + numCssChanges); - console.log("new css: " + cssText); + console.log("css: " + cssText); } }); } \ No newline at end of file diff --git a/tools/tests/apps/hot-code-push-test/.meteor/.gitignore b/tools/tests/apps/hot-code-push-test/.meteor/.gitignore new file mode 100644 index 0000000000..4083037423 --- /dev/null +++ b/tools/tests/apps/hot-code-push-test/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/tools/tests/apps/hot-code-push-test/.meteor/identifier b/tools/tests/apps/hot-code-push-test/.meteor/identifier new file mode 100644 index 0000000000..c4e9c530a1 --- /dev/null +++ b/tools/tests/apps/hot-code-push-test/.meteor/identifier @@ -0,0 +1 @@ +1da9lx3m24vwv1kt1w0a \ No newline at end of file diff --git a/tools/tests/apps/hot-code-push-test/.meteor/packages b/tools/tests/apps/hot-code-push-test/.meteor/packages new file mode 100644 index 0000000000..418fbb3854 --- /dev/null +++ b/tools/tests/apps/hot-code-push-test/.meteor/packages @@ -0,0 +1,6 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +standard-app-packages \ No newline at end of file diff --git a/tools/tests/apps/hot-code-push-test/.meteor/release b/tools/tests/apps/hot-code-push-test/.meteor/release new file mode 100644 index 0000000000..621e94f0ec --- /dev/null +++ b/tools/tests/apps/hot-code-push-test/.meteor/release @@ -0,0 +1 @@ +none diff --git a/tools/tests/apps/hot-code-push-test/.meteor/versions b/tools/tests/apps/hot-code-push-test/.meteor/versions new file mode 100644 index 0000000000..f41bbf047b --- /dev/null +++ b/tools/tests/apps/hot-code-push-test/.meteor/versions @@ -0,0 +1,40 @@ +application-configuration@1.0.0 +autopublish@1.0.0 +autoupdate@1.0.0 +binary-heap@1.0.0 +callback-hook@1.0.0 +check@1.0.0 +ctl-helper@1.0.0 +ctl@1.0.0 +deps@1.0.0 +ejson@1.0.0 +follower-livedata@1.0.0 +geojson-utils@1.0.0 +html-tools@1.0.0 +htmljs@1.0.0 +id-map@1.0.0 +insecure@1.0.0 +jquery@1.0.0 +json@1.0.0 +livedata@1.0.0 +logging@1.0.0 +meteor@1.0.0 +minifiers@1.0.0 +minimongo@1.0.0 +mongo-livedata@1.0.0 +observe-sequence@1.0.0 +ordered-dict@1.0.0 +random@1.0.0 +reactive-dict@1.0.0 +reload@1.0.0 +retry@1.0.0 +routepolicy@1.0.0 +session@1.0.0 +spacebars-common@1.0.0 +spacebars-compiler@1.0.0 +spacebars@1.0.0 +standard-app-packages@1.0.0 +templating@1.0.0 +ui@1.0.0 +underscore@1.0.0 +webapp@1.0.0 diff --git a/tools/tests/apps/hot-code-push-test/hot-code-push-test.js b/tools/tests/apps/hot-code-push-test/hot-code-push-test.js new file mode 100644 index 0000000000..d88a8acf84 --- /dev/null +++ b/tools/tests/apps/hot-code-push-test/hot-code-push-test.js @@ -0,0 +1,18 @@ +if (Meteor.isClient) { + Meteor.startup(function () { + Meteor.call("clientLoad", typeof jsVar === 'undefined' ? 'undefined' : jsVar); + }); +} + +if (Meteor.isServer) { + var clientConnections; + Meteor.startup(function () { + clientConnections = 0; + }); + Meteor.methods({ + clientLoad: function (jsVar) { + console.log("client connected: " + clientConnections++); + console.log("jsVar: " + jsVar); + } + }); +} \ No newline at end of file diff --git a/tools/tests/hot-code-push.js b/tools/tests/hot-code-push.js index 9b631a670a..b2c330ea78 100644 --- a/tools/tests/hot-code-push.js +++ b/tools/tests/hot-code-push.js @@ -13,39 +13,130 @@ selftest.define("css injection", function (options) { s.createApp("myapp", "css-injection-test"); s.cd("myapp"); - s.testWithAllClients(function (run) { - s.set("METEOR_TEST_TMP", files.mkdtemp()); + run.baseTimeout = 20; run.match("myapp"); run.match("proxy"); run.match("MongoDB"); - run.waitSecs(20); run.match("running at"); run.match("localhost"); run.connectClient(); - run.waitSecs(60); - run.match("client connected"); - - // Initially there is no CSS file. run.waitSecs(20); - run.match("numCssChanges: 0"); - run.match("new css:"); + run.match("client connected"); // 'numCssChanges' variable is set to 0 on a client refresh. // Since CSS changes should not trigger a client refresh, numCssChanges // should never reset. - // XXX change test expectations when CSS injection patch lands. + // The css file is initially empty. + run.match("numCssChanges: 0"); + run.match("css: \n"); + + // The server restarts if a new css file is added. s.write("test.css", "body { background-color: red; }"); - run.waitSecs(20); - run.match("numCssChanges: 0"); - run.match("new css: body { background-color: red; }"); - s.write("test.css", "body { background-color: blue; }"); - run.waitSecs(20); - run.match("numCssChanges: 0"); - run.match("new css: body { background-color: blue; }"); + run.match("server restarted"); + run.match("numCssChanges: 1"); + run.match("css: body { background-color: red; }"); + + s.write("test.css", "body { background-color: orange; }"); + run.match("refreshing"); + run.match("numCssChanges: 2"); + run.match("css: body { background-color: orange; }"); + + // The server restarts if a css file is removed. + s.unlink("test.css"); + run.match("server restarted"); + run.match("numCssChanges: 3"); + run.match("css: \n"); + run.stop(); + }); +}); + +selftest.define("javascript hot code push", function (options) { + var s = new Sandbox({ + clients: options.clients, + }); + + s.createApp("myapp", "hot-code-push-test"); + s.cd("myapp"); + s.testWithAllClients(function (run) { + run.baseTimeout = 20; + run.match("myapp"); + run.match("proxy"); + run.match("MongoDB"); + run.match("running at"); + run.match("localhost"); + + run.connectClient(); + run.waitSecs(20); + + // There is initially no JavaScript file. + run.match("client connected: 0"); + run.match("jsVar: undefined"); + + // The server and client both restart if a shared js file is added + // or removed. + s.write("test.js", "jsVar = 'foo'"); + run.match("server restarted"); + run.match("client connected: 0"); + run.match("jsVar: foo"); + + s.unlink("test.js"); + run.match("server restarted"); + run.match("client connected: 0"); + run.match("jsVar: undefined"); + + // Only the client should refresh if a client js file is added. Thus, + // "client connected" variable will be incremented. + s.write("client/test.js", "jsVar = 'bar'"); + run.match("client connected: 1"); + run.match("jsVar: bar"); + + s.unlink("client/test.js"); + run.match("client connected: 2"); + run.match("jsVar: undefined"); + + // When we change a server file the client should not refresh. We observe + // this by changing a server file and then a client file and verifying + // that the client has only connected once. + s.write("server/test.js", "jsVar = 'bar'"); + run.match("server restarted"); + s.write("client/empty.js", ""); + run.match("client connected: 0"); + run.match("jsVar: undefined"); // cannot access a server variable from the client. + + s.unlink("server/test.js"); + run.match("server restarted"); + s.unlink("client/empty.js"); + run.match("client connected: 0"); + run.match("jsVar: undefined"); + + // Add appcache and ensure that the browser still reloads. + s.write(".meteor/packages", "standard-app-packages \n appcache"); + run.match("added appcache"); + run.match("server restarted"); + run.match("client connected: 0"); + run.match("jsVar: undefined"); + + s.write("client/test.js", "jsVar = 'bar'"); + run.match("client connected: 1"); + run.match("jsVar: bar"); + + // Remove appcache and ensure that the browser still reloads. + s.write(".meteor/packages", "standard-app-packages"); + run.match("removed"); + run.match("appcache"); + run.match("server restarted"); + run.match("client connected: 0"); + run.match("jsVar: bar"); + + s.write("client/test.js", "jsVar = 'baz'"); + run.match("client connected: 1"); + run.match("jsVar: baz"); + s.unlink("client/test.js"); + run.stop(); }); });