UNPKG

systemjs-hot-reloader-ex

Version:

SystemJS / JSPM hot reloader with support of CSS, SCSS, SASS, LESS, Stylus, React and JavaScript

472 lines (398 loc) 13.4 kB
import 'core-js/shim'; export default class SystemHotReloader { /** * Constructor. * * @param {Object} options * @prop {Object} loader SystemJS instance, default to SystemJS || System * @prop {Number} logLevel 0 - none, 1 - error, 2 - info (default), 3 - debug */ constructor(options) { const opts = options || {}; this.loader = opts.loader || SystemJS || System; if (!this.loader) { throw new Error('Unable to instantiate SystemJS Hot Reloader without SystemJS'); } if (this.loader.hotReloaderOptions) { Object.assign(opts, this.loader.hotReloaderOptions); } this.logLevel = opts.logLevel === undefined ? 2 : opts.logLevel; this.enableClearResources = opts.clearResources === undefined ? true : opts.clearResources; this.logger = this.createLogger('HMR'); if (!this.loader.trace) { this.logger.error('Set "SystemJS.trace = true" to enable hot reload'); } if (/^0\.20\./.test(this.loader.version)) { throw new Error('This version of SystemJS hot reloader is designed for SystemJS v0.20.x'); } } /** * Create logger. */ createLogger(prefix) { return { debug: (message) => { if (this.logLevel >= 3 && console && console.debug) { console.debug(`[${prefix}] ${message}`); } }, info: (message) => { if (this.logLevel >= 2 && console && console.info) { console.info(`[${prefix}] ${message}`); } }, error: (message) => { if (this.logLevel >= 1 && console && console.warn) { console.warn(`[${prefix}] ${message}`); } }, }; } /** * Resolve module file path to module name. */ resolvePath(path) { // try obvious resolve filename.ext => filename.ext const name1 = this.loader.normalizeSync(path); if (this.loader.get(name1)) { return name1; } // try less obvious resolve filename.ext => filename.ext! const name2 = this.loader.normalizeSync(`${path}!`); if (this.loader.get(name2)) { return name2; } // try to find by filename path in all registered modules, slow :-( const name3 = Object.keys(this.loader.loads).find((name) => { return name.startsWith(`${name1}!`); }); if (name3) { return name3; } return undefined; } /** * Reload module by file path. */ reloadPath(path) { this.logger.debug(`Reloading file: ${path}`); const name = this.resolvePath(path); if (name) { return this.reloadModule(name); } // we did not find module :-( this.logger.info('Nothing to update'); return Promise.resolve(); } /** * Clean full module name from useless base url prefix and loader related suffix. */ cleanName(name) { // remove base url prefix if (name.startsWith(this.loader.baseURL)) { name = `./${name.substr(this.loader.baseURL.length)}`; } // remove loader related garbage return name.replace(/!.*$/, ''); } /** * Reload module by full module name. */ reloadModule(moduleName) { const startTime = window.performance.now(); this.logger.info(`Reloading module ${this.cleanName(moduleName)}`); if (!this.loader.get(moduleName)) { this.logger.info('Nothing to update'); return Promise.resolve(); } const moduleChain = this.getReloadChain([moduleName]); const moduleBackups = {}; return Promise.resolve() .then(() => { this.logger.debug('Reload chain:'); moduleChain.forEach((name) => { this.logger.debug(` - ${this.cleanName(name)}`); }); }) .then(() => { this.logger.debug('Saving backup'); moduleChain.forEach((name) => { moduleBackups[name] = this.getModuleBackup(name); }); }) .then(() => { let promise = Promise.resolve(); moduleChain.forEach((name) => { promise = promise.then(() => this.reloadModuleInstance(name, moduleChain)); }); return promise; }) .then(() => { if (moduleChain.length) { this.logger.info('Updated modules:'); moduleChain.forEach((name) => { const exports = this.loader.get(name); const options = []; if (exports && exports.__reload) { options.push('__reload()'); } else if (exports && exports.__unload) { options.push('__unload()'); } const suffix = options.length ? `{ ${options.join(', ')} }` : ''; this.logger.info(` - ${this.cleanName(name)} ${suffix}`); }); } else { this.logger.info('Nothing to update'); } const time = (window.performance.now() - startTime) / 1000; const timeSecRound = Math.floor(time * 100) / 100; this.logger.info(`Reload took ${timeSecRound} sec`); }) .catch((error) => { if (error) { const realError = error.originalErr || error; this.logger.error(realError.stack || realError); } this.logger.error('An error occured during reloading. Reverting...'); let promise = Promise.resolve(); moduleChain.forEach((name) => { promise = promise.then(() => { return this.reloadModuleInstance(name, moduleChain, moduleBackups[name]); }); }); promise = promise.then(() => { this.logger.info('Application state was restored'); }); return promise; }) .catch((error) => { if (error) { this.logger.error(error.stack || error); } this.logger.error('An unrecoverable error occured during reverting'); }); } /** * Reload module instance with option to reload from backup. */ reloadModuleInstance(name, moduleChain, backup) { const exports = backup ? backup.exports : this.loader.get(name); const unload = exports ? exports.__unload : undefined; const reload = exports ? exports.__reload : undefined; let oldDeps = []; if (reload) { return Promise.resolve() .then(() => this.fixModuleDeps(name)) .then(() => { this.logger.debug(`Calling module ${this.cleanName(name)} __reload() hook`); return reload(moduleChain); }); } return Promise.resolve() .then(() => { if (!unload) { return undefined; } this.logger.debug(`Calling module ${this.cleanName(name)} unload() hook`); return unload(moduleChain); }) .then(() => { oldDeps = this.getModuleDepNames(name); }) .then(() => (backup ? this.restoreModuleBackup(backup) : this.deleteModule(name))) .then(() => this.importModule(name)) .then(() => { const newDeps = this.getModuleDepNames(name); this.deleteOldDeps(oldDeps, newDeps); }); } /** * Delete modules from oldDeps if they do not exist in newDeps. */ deleteOldDeps(oldDeps, newDeps) { const depDiff = oldDeps.filter(depName => newDeps.indexOf(depName) === -1); depDiff.forEach(depName => this.deleteModule(depName)); } /** * Get normalized list of module dependency names based on trace information * or based on module records. */ getModuleDepNames(name) { const load = this.loader.loads[name]; if (load) { return load.deps.map((address) => { return load.depMap[address]; }); } // fix me const moduleRecord = this.loader._loader.moduleRecords[name]; if (moduleRecord) { return moduleRecord.dependencies .map(record => (record ? record.name : false)) .filter(depName => !!depName); } return []; } /** * Fix module dependencies before hooked reload. */ fixModuleDeps(name) { const moduleRecords = this.loader._loader.moduleRecords; const moduleRecord = moduleRecords[name]; moduleRecord.dependencies .forEach((depModuleRecord, index) => { if (!depModuleRecord) { return; } const newDepModuleRecord = moduleRecords[depModuleRecord.name]; if (!newDepModuleRecord) { return; } if (newDepModuleRecord !== depModuleRecord) { this.logger.debug(`Fixing dependency ${this.cleanName(depModuleRecord.name)} for module ${this.cleanName(moduleRecord.name)}`); moduleRecord.setters[index](newDepModuleRecord.exports); if (moduleRecord.dependencies[index] !== newDepModuleRecord.exports) { moduleRecord.dependencies[index] = newDepModuleRecord; } const impRecord = newDepModuleRecord.importers .find(record => record && record.name === moduleRecord); if (!impRecord) { newDepModuleRecord.importers.push(moduleRecord); } } }); } /** * Import module. */ importModule(name) { this.logger.debug(`Importing module ${this.cleanName(name)}`); return this.loader.import(name); } /** * Delete module and fix importers for dependencies. */ deleteModule(name) { const moduleRecord = this.loader._loader.moduleRecords[name]; if (moduleRecord) { moduleRecord.dependencies .forEach((depModuleRecord) => { if (!depModuleRecord) { return; } depModuleRecord.importers .forEach((impModuleRecord, index) => { if (impModuleRecord && moduleRecord.name === impModuleRecord.name) { this.logger.debug(`Removing importer ${this.cleanName(impModuleRecord.name)} from module ${this.cleanName(depModuleRecord.name)}`); depModuleRecord.importers.splice(index, 1); } }); }); } this.logger.debug(`Removing module ${this.cleanName(name)}`); this.loader.delete(name); if (this.enableClearResources) { this.clearModuleResources(name); } } /** * Clear module resources located in DOM. * * Usefull for CSS/LESS/SASS/SCSS/Stylus plugins who keep CSS as style or link tags. */ clearModuleResources(name) { const address = this.getModuleAddress(name); const removeNode = (node) => { if (window.URL && node.href.startsWith('blob:')) { URL.revokeObjectURL(node.href); } node.remove(); }; // for example, plugin-sass Array.from(document.querySelectorAll(`[data-url="${address}"]`)) .forEach(node => removeNode(node)); // for example, plugin-css Array.from(document.querySelectorAll('[data-systemjs-css]')) .filter(node => node.href === address) .forEach(node => removeNode(node)); } /** * Get module address by name. * * Try to use trace information if availabe, if not then try to guess it. * Address guessing should work well for plugins without custom translation hook. */ getModuleAddress(name) { const load = this.loader.loads[name]; return load ? load.address : name.replace(/!.*$/, ''); } /** * Get module backup which could be used to restore module state. */ getModuleBackup(name) { const exports = this.loader.get(name); const record = this.loader._loader.moduleRecords[name]; return { name, record, exports }; } /** * Restore module from backup. */ restoreModuleBackup(data) { this.loader.set(data.name, data.exports); this.loader._loader.moduleRecords[data.name] = data.record; } /** * Get shortest distance to the root module (root modules have no importers). */ getModuleDistanceToRoot(name, record, cache) { let distance; if (cache[name] !== undefined) { return cache[name]; } if (!record || !record.importers.length) { distance = 0; } else { distance = record.importers.reduce((result, impRecord) => { const impDistance = 1 + this.getModuleDistanceToRoot(impRecord.name, impRecord, cache); return result === null ? impDistance : Math.min(result, impDistance); }, null); } cache[name] = distance; return distance; } /** * Reduce dependency tree and return modules in the order they should be reloaded. */ getReloadChain(modules, cache) { if (modules.length === 0) { return modules; } const records = this.loader._loader.moduleRecords; if (!cache) { cache = {}; } const farNode = modules.reduce((result, name, index) => { const record = records[name] ? records[name] : undefined; const distance = this.getModuleDistanceToRoot(name, record, cache); const importers = !record ? [] : record.importers.map(item => item.name); const reload = record && record.exports && record.exports.__reload; const meta = { distance, index, name, importers, reload }; if (result === undefined) { return meta; } return result.distance >= distance ? result : meta; }, undefined); const nextModules = modules.slice(0); nextModules.splice(farNode.index, 1); if (!farNode.reload) { farNode.importers.forEach((name) => { if (nextModules.indexOf(name) === -1) { nextModules.push(name); } }); } const nextResult = this.getReloadChain(nextModules, cache); const result = [farNode.name].concat(nextResult); return result; } }