diff --git a/bundle-config-loader.js b/bundle-config-loader.js index cf3d70fb..343e25b7 100644 --- a/bundle-config-loader.js +++ b/bundle-config-loader.js @@ -8,7 +8,18 @@ module.exports = function (source) { `; if (!angular && registerModules) { + const hmr = ` + if (module.hot) { + global.__hmrLivesyncBackup = global.__onLiveSync; + global.__onLiveSync = function () { + console.log("LiveSyncing..."); + require("nativescript-dev-webpack/hot")("", {}); + }; + } + `; + source = ` + ${hmr} const context = require.context("~/", true, ${registerModules}); global.registerWebpackModules(context); ${source} diff --git a/hot-loader-helper.js b/hot-loader-helper.js new file mode 100644 index 00000000..cc5dcd76 --- /dev/null +++ b/hot-loader-helper.js @@ -0,0 +1,11 @@ +module.exports.reload = ` + if (module.hot) { + module.hot.accept(); + module.hot.dispose(() => { + setTimeout(() => { + global.__hmrLivesyncBackup(); + }); + }) + } +`; + diff --git a/hot.js b/hot.js new file mode 100644 index 00000000..90c00665 --- /dev/null +++ b/hot.js @@ -0,0 +1,140 @@ +const log = console; +const refresh = 'Please refresh the page.'; +const hotOptions = { + ignoreUnaccepted: true, + ignoreDeclined: true, + ignoreErrored: true, + onUnaccepted(data) { + const chain = [].concat(data.chain); + const last = chain[chain.length - 1]; + + if (last === 0) { + chain.pop(); + } + + log.warn(`Ignored an update to unaccepted module ${chain.join(' ➭ ')}`); + }, + onDeclined(data) { + log.warn(`Ignored an update to declined module ${data.chain.join(' ➭ ')}`); + }, + onErrored(data) { + log.warn( + `Ignored an error while updating module ${data.moduleId} <${data.type}>` + ); + log.warn(data.error); + }, +}; + +let lastHash; + +function upToDate() { + return lastHash.indexOf(__webpack_hash__) >= 0; +} + +function result(modules, appliedModules) { + const unaccepted = modules.filter( + (moduleId) => appliedModules && appliedModules.indexOf(moduleId) < 0 + ); + + if (unaccepted.length > 0) { + let message = 'The following modules could not be updated:'; + + for (const moduleId of unaccepted) { + message += `\n ⦻ ${moduleId}`; + } + log.warn(message); + } + + if (!(appliedModules || []).length) { + console.info('No Modules Updated.'); + } else { + const message = ['The following modules were updated:']; + + for (const moduleId of appliedModules) { + message.push(` ↻ ${moduleId}`); + } + + console.info(message.join('\n')); + + const numberIds = appliedModules.every( + (moduleId) => typeof moduleId === 'number' + ); + if (numberIds) { + console.info( + 'Please consider using the NamedModulesPlugin for module names.' + ); + } + } +} + +function check(options) { + module.hot + .check() + .then((modules) => { + if (!modules) { + log.warn( + `Cannot find update. The server may have been restarted. ${refresh}` + ); + return null; + } + + return module.hot + .apply(hotOptions) + .then((appliedModules) => { + if (!upToDate()) { + log.warn("Hashes don't match. Ignoring second update..."); + // check(options); + } + + result(modules, appliedModules); + + if (upToDate()) { + console.info('App is up to date.'); + } + }) + .catch((err) => { + const status = module.hot.status(); + if (['abort', 'fail'].indexOf(status) >= 0) { + log.warn(`Cannot apply update. ${refresh}`); + log.warn(err.stack || err.message); + if (options.reload) { + window.location.reload(); + } + } else { + log.warn(`Update failed: ${err.stack}` || err.message); + } + }); + }) + .catch((err) => { + const status = module.hot.status(); + if (['abort', 'fail'].indexOf(status) >= 0) { + log.warn(`Cannot check for update. ${refresh}`); + log.warn(err.stack || err.message); + } else { + log.warn(`Update check failed: ${err.stack}` || err.message); + } + }); +} + +if (module.hot) { + console.info('Hot Module Replacement Enabled. Waiting for signal.'); +} else { + console.error('Hot Module Replacement is disabled.'); +} + +module.exports = function update(currentHash, options) { + lastHash = currentHash; + if (!upToDate()) { + const status = module.hot.status(); + + if (status === 'idle') { + console.info('Checking for updates to the bundle.'); + check(options); + } else if (['abort', 'fail'].indexOf(status) >= 0) { + log.warn( + `Cannot apply update. A previous update ${status}ed. ${refresh}` + ); + } + } +}; + diff --git a/lib/before-prepareJS.js b/lib/before-prepareJS.js index 62fffbcf..a1b9a6dd 100644 --- a/lib/before-prepareJS.js +++ b/lib/before-prepareJS.js @@ -1,7 +1,8 @@ const { runWebpackCompiler } = require("./compiler"); -module.exports = function ($logger, $liveSyncService, hookArgs) { +module.exports = function ($logger, $liveSyncService, $options, hookArgs) { const env = hookArgs.config.env || {}; + env.hmr = !!$options.hmr; const platform = hookArgs.config.platform; const appFilesUpdaterOptions = hookArgs.config.appFilesUpdaterOptions; const config = { diff --git a/lib/before-watch.js b/lib/before-watch.js index f3d01e87..e732a65c 100644 --- a/lib/before-watch.js +++ b/lib/before-watch.js @@ -1,22 +1,23 @@ const { runWebpackCompiler } = require("./compiler"); -module.exports = function ($logger, $liveSyncService, hookArgs) { - if (hookArgs.config) { - const appFilesUpdaterOptions = hookArgs.config.appFilesUpdaterOptions; - if (appFilesUpdaterOptions.bundle) { - const platforms = hookArgs.config.platforms; - return Promise.all(platforms.map(platform => { - const env = hookArgs.config.env || {}; - const config = { - env, - platform, - bundle: appFilesUpdaterOptions.bundle, - release: appFilesUpdaterOptions.release, - watch: true - }; +module.exports = function ($logger, $liveSyncService, $options, hookArgs) { + if (hookArgs.config) { + const appFilesUpdaterOptions = hookArgs.config.appFilesUpdaterOptions; + if (appFilesUpdaterOptions.bundle) { + const platforms = hookArgs.config.platforms; + return Promise.all(platforms.map(platform => { + const env = hookArgs.config.env || {}; + env.hmr = !!$options.hmr; + const config = { + env, + platform, + bundle: appFilesUpdaterOptions.bundle, + release: appFilesUpdaterOptions.release, + watch: true + }; - return runWebpackCompiler(config, hookArgs.projectData, $logger, $liveSyncService, hookArgs); - })); - } - } + return runWebpackCompiler(config, hookArgs.projectData, $logger, $liveSyncService, hookArgs); + })); + } + } } diff --git a/markup-hot-loader.js b/markup-hot-loader.js new file mode 100644 index 00000000..3b4f19c2 --- /dev/null +++ b/markup-hot-loader.js @@ -0,0 +1,5 @@ +const { reload } = require("./hot-loader-helper"); + +module.exports = function (source) { + return `${source};${reload}`; +}; diff --git a/page-hot-loader.js b/page-hot-loader.js new file mode 100644 index 00000000..3b4f19c2 --- /dev/null +++ b/page-hot-loader.js @@ -0,0 +1,5 @@ +const { reload } = require("./hot-loader-helper"); + +module.exports = function (source) { + return `${source};${reload}`; +}; diff --git a/plugins/WatchStateLoggerPlugin.ts b/plugins/WatchStateLoggerPlugin.ts index 690b24bf..a3fb0813 100644 --- a/plugins/WatchStateLoggerPlugin.ts +++ b/plugins/WatchStateLoggerPlugin.ts @@ -1,4 +1,5 @@ import { join } from "path"; +import { writeFileSync, readFileSync } from "fs"; export enum messages { compilationComplete = "Webpack compilation complete.", @@ -24,6 +25,7 @@ export class WatchStateLoggerPlugin { }); compiler.hooks.afterEmit.tapAsync("WatchStateLoggerPlugin", function (compilation, callback) { callback(); + if (plugin.isRunningWatching) { console.log(messages.startWatching); } else { @@ -32,12 +34,93 @@ export class WatchStateLoggerPlugin { const emittedFiles = Object .keys(compilation.assets) - .filter(assetKey => compilation.assets[assetKey].emitted) + .filter(assetKey => compilation.assets[assetKey].emitted); + + if (compilation.errors.length > 0) { + WatchStateLoggerPlugin.rewriteHotUpdateChunk(compiler, compilation, emittedFiles); + } + + // provide fake paths to the {N} CLI - relative to the 'app' folder + // in order to trigger the livesync process + const emittedFilesFakePaths = emittedFiles .map(file => join(compiler.context, file)); process.send && process.send(messages.compilationComplete, error => null); // Send emitted files so they can be LiveSynced if need be - process.send && process.send({ emittedFiles }, error => null); + process.send && process.send({ emittedFiles: emittedFilesFakePaths }, error => null); }); } + + /** + * Rewrite an errored chunk to make the hot module replace successful. + * @param compiler the webpack compiler + * @param emittedFiles the emitted files from the current compilation + */ + private static rewriteHotUpdateChunk(compiler, compilation, emittedFiles: string[]) { + const chunk = this.findHotUpdateChunk(emittedFiles); + if (!chunk) { + return; + } + + const { name } = this.parseHotUpdateChunkName(chunk); + if (!name) { + return; + } + + const absolutePath = join(compiler.outputPath, chunk); + + const newContent = this.getWebpackHotUpdateReplacementContent(compilation.errors, absolutePath, name); + writeFileSync(absolutePath, newContent); + } + + private static findHotUpdateChunk(emittedFiles: string[]) { + return emittedFiles.find(file => file.endsWith("hot-update.js")); + } + + /** + * Gets only the modules object after 'webpackHotUpdate("bundle",' in the chunk + */ + private static getModulesObjectFromChunk(chunkPath) { + let content = readFileSync(chunkPath, "utf8") + const startIndex = content.indexOf(",") + 1; + let endIndex = content.length - 1; + if(content.endsWith(';')) { + endIndex--; + } + return content.substring(startIndex, endIndex); + } + + /** + * Gets the webpackHotUpdate call with updated modules not to include the ones with errors + */ + private static getWebpackHotUpdateReplacementContent(compilationErrors, filePath, moduleName) { + const errorModuleIds = compilationErrors.filter(x => x.module).map(x => x.module.id); + if (!errorModuleIds || errorModuleIds.length == 0) { + // could not determine error modiles so discard everything + return `webpackHotUpdate('${moduleName}', {});`; + } + const updatedModules = this.getModulesObjectFromChunk(filePath); + + // we need to filter the modules with a function in the file as it is a relaxed JSON not valid to be parsed and manipulated + return `const filter = function(updatedModules, modules) { + modules.forEach(moduleId => delete updatedModules[moduleId]); + return updatedModules; + } + webpackHotUpdate('${moduleName}', filter(${updatedModules}, ${JSON.stringify(errorModuleIds)}));`; + } + + /** + * Parse the filename of the hot update chunk. + * @param name bundle.deccb264c01d6d42416c.hot-update.js + * @returns { name: string, hash: string } { name: 'bundle', hash: 'deccb264c01d6d42416c' } + */ + private static parseHotUpdateChunkName(name) { + const matcher = /^(.+)\.(.+)\.hot-update/gm; + const matches = matcher.exec(name); + + return { + name: matches[1] || "", + hash: matches[2] || "", + }; + } } diff --git a/style-hot-loader.js b/style-hot-loader.js new file mode 100644 index 00000000..3b4f19c2 --- /dev/null +++ b/style-hot-loader.js @@ -0,0 +1,5 @@ +const { reload } = require("./hot-loader-helper"); + +module.exports = function (source) { + return `${source};${reload}`; +}; diff --git a/templates/webpack.angular.js b/templates/webpack.angular.js index 8fe26743..cf7e9dc5 100644 --- a/templates/webpack.angular.js +++ b/templates/webpack.angular.js @@ -42,6 +42,7 @@ module.exports = env => { uglify, // --env.uglify report, // --env.report sourceMap, // --env.sourceMap + hmr, // --env.hmr } = env; const appFullPath = resolve(projectRoot, appPath); @@ -265,5 +266,9 @@ module.exports = env => { })); } + if (hmr) { + config.plugins.push(new webpack.HotModuleReplacementPlugin()); + } + return config; }; diff --git a/templates/webpack.javascript.js b/templates/webpack.javascript.js index 2795ee65..da5748b1 100644 --- a/templates/webpack.javascript.js +++ b/templates/webpack.javascript.js @@ -40,6 +40,7 @@ module.exports = env => { uglify, // --env.uglify report, // --env.report sourceMap, // --env.sourceMap + hmr, // --env.hmr } = env; const appFullPath = resolve(projectRoot, appPath); @@ -150,6 +151,21 @@ module.exports = env => { ].filter(loader => !!loader) }, + { + test: /-page\.js$/, + use: "nativescript-dev-webpack/page-hot-loader" + }, + + { + test: /\.(css|scss)$/, + use: "nativescript-dev-webpack/style-hot-loader" + }, + + { + test: /\.(html|xml)$/, + use: "nativescript-dev-webpack/markup-hot-loader" + }, + { test: /\.(html|xml)$/, use: "nativescript-dev-webpack/xml-namespace-loader"}, { @@ -163,7 +179,7 @@ module.exports = env => { { loader: "css-loader", options: { minimize: false, url: false } }, "sass-loader" ] - } + }, ] }, plugins: [ @@ -227,5 +243,10 @@ module.exports = env => { })); } + if (hmr) { + config.plugins.push(new webpack.HotModuleReplacementPlugin()); + } + + return config; }; diff --git a/templates/webpack.typescript.js b/templates/webpack.typescript.js index 5a7cbf9a..e3673cde 100644 --- a/templates/webpack.typescript.js +++ b/templates/webpack.typescript.js @@ -40,6 +40,7 @@ module.exports = env => { uglify, // --env.uglify report, // --env.report sourceMap, // --env.sourceMap + hmr, // --env.hmr } = env; const appFullPath = resolve(projectRoot, appPath); @@ -152,6 +153,21 @@ module.exports = env => { ].filter(loader => !!loader) }, + { + test: /-page\.ts$/, + use: "nativescript-dev-webpack/page-hot-loader" + }, + + { + test: /\.(css|scss)$/, + use: "nativescript-dev-webpack/style-hot-loader" + }, + + { + test: /\.(html|xml)$/, + use: "nativescript-dev-webpack/markup-hot-loader" + }, + { test: /\.(html|xml)$/, use: "nativescript-dev-webpack/xml-namespace-loader"}, { @@ -237,5 +253,10 @@ module.exports = env => { })); } + if (hmr) { + config.plugins.push(new webpack.HotModuleReplacementPlugin()); + } + + return config; }; diff --git a/xml-namespace-loader.js b/xml-namespace-loader.js index 9e97a941..2cb83c3a 100644 --- a/xml-namespace-loader.js +++ b/xml-namespace-loader.js @@ -82,7 +82,7 @@ module.exports = function (source) { .replace(/\u2028/g, '\\u2028') .replace(/\u2029/g, '\\u2029'); - const wrapped = `${moduleRegisters}\nmodule.exports = ${json}`; + const wrapped = `${moduleRegisters}\nmodule.exports = ${json};`; this.callback(null, wrapped); }