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
JavaScript
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;
}
}