livereload-js
Version:
LiveReload JS client - auto reload browser on changes
612 lines (492 loc) • 16.2 kB
JavaScript
/* global CSSRule */
/**
* Split URL
* @param {string} url
* @return {object}
*/
function splitUrl (url) {
let hash = '';
let params = '';
let index = url.indexOf('#');
if (index >= 0) {
hash = url.slice(index);
url = url.slice(0, index);
}
// http://your.domain.com/path/to/combo/??file1.css,file2,css
const comboSign = url.indexOf('??');
if (comboSign >= 0) {
if ((comboSign + 1) !== url.lastIndexOf('?')) {
index = url.lastIndexOf('?');
}
} else {
index = url.indexOf('?');
}
if (index >= 0) {
params = url.slice(index);
url = url.slice(0, index);
}
return { url, params, hash };
};
/**
* Get path from URL (remove protocol, host, port)
* @param {string} url
* @return {string}
*/
function pathFromUrl (url) {
if (!url) {
return '';
}
let path;
({ url } = splitUrl(url));
if (url.indexOf('file://') === 0) {
// eslint-disable-next-line prefer-regex-literals
path = url.replace(new RegExp('^file://(localhost)?'), '');
} else {
// http : // hostname :8080 /
// eslint-disable-next-line prefer-regex-literals
path = url.replace(new RegExp('^([^:]+:)?//([^:/]+)(:\\d*)?/'), '/');
}
// decodeURI has special handling of stuff like semicolons, so use decodeURIComponent
return decodeURIComponent(path);
}
/**
* Get number of matching path segments
* @param {string} left
* @param {string} right
* @return {int}
*/
function numberOfMatchingSegments (left, right) {
// get rid of leading slashes and normalize to lower case
left = left.replace(/^\/+/, '').toLowerCase();
right = right.replace(/^\/+/, '').toLowerCase();
if (left === right) {
return 10000;
}
const comps1 = left.split(/\/|\\/).reverse();
const comps2 = right.split(/\/|\\/).reverse();
const len = Math.min(comps1.length, comps2.length);
let eqCount = 0;
while ((eqCount < len) && (comps1[eqCount] === comps2[eqCount])) {
++eqCount;
}
return eqCount;
}
/**
* Pick best matching path from a collection
* @param {string} path Path to match
* @param {array} objects Collection of paths
* @param {function} [pathFunc] Transform applied to each item in collection
* @return {object}
*/
function pickBestMatch (path, objects, pathFunc = s => s) {
let score;
let bestMatch = { score: 0 };
for (const object of objects) {
score = numberOfMatchingSegments(path, pathFunc(object));
if (score > bestMatch.score) {
bestMatch = { object, score };
}
}
if (bestMatch.score === 0) {
return null;
}
return bestMatch;
}
/**
* Test if paths match
* @param {string} left
* @param {string} right
* @return {bool}
*/
function pathsMatch (left, right) {
return numberOfMatchingSegments(left, right) > 0;
}
const IMAGE_STYLES = [
{ selector: 'background', styleNames: ['backgroundImage'] },
{ selector: 'border', styleNames: ['borderImage', 'webkitBorderImage', 'MozBorderImage'] }
];
const DEFAULT_OPTIONS = {
stylesheetReloadTimeout: 15000
};
const IMAGES_REGEX = /\.(jpe?g|png|gif|svg)$/i;
class Reloader {
constructor (window, console, Timer) {
this.window = window;
this.console = console;
this.Timer = Timer;
this.document = this.window.document;
this.importCacheWaitPeriod = 200;
this.plugins = [];
}
addPlugin (plugin) {
return this.plugins.push(plugin);
}
analyze (callback) {
}
reload (path, options = {}) {
this.options = {
...DEFAULT_OPTIONS,
...options
}; // avoid passing it through all the funcs
if (this.options.pluginOrder && this.options.pluginOrder.length) {
this.runPluginsByOrder(path, options);
return;
}
for (const plugin of Array.from(this.plugins)) {
if (plugin.reload && plugin.reload(path, options)) {
return;
}
}
if (options.liveCSS && path.match(/\.css(?:\.map)?$/i)) {
if (this.reloadStylesheet(path)) {
return;
}
}
if (options.liveImg && path.match(IMAGES_REGEX)) {
this.reloadImages(path);
return;
}
if (options.isChromeExtension) {
this.reloadChromeExtension();
return;
}
return this.reloadPage();
}
runPluginsByOrder (path, options) {
options.pluginOrder.some(pluginId => {
if (pluginId === 'css') {
if (options.liveCSS && path.match(/\.css(?:\.map)?$/i)) {
if (this.reloadStylesheet(path)) {
return true;
}
}
}
if (pluginId === 'img') {
if (options.liveImg && path.match(IMAGES_REGEX)) {
this.reloadImages(path);
return true;
}
}
if (pluginId === 'extension') {
if (options.isChromeExtension) {
this.reloadChromeExtension();
return true;
}
}
if (pluginId === 'others') {
this.reloadPage();
return true;
}
if (pluginId === 'external') {
return this.plugins.some(
plugin => plugin.reload && plugin.reload(path, options)
);
}
return this.plugins.filter(plugin => plugin.constructor.identifier === pluginId)
.some(plugin => plugin.reload && plugin.reload(path, options));
});
}
reloadPage () {
return this.window.document.location.reload();
}
reloadChromeExtension () {
return this.window.chrome.runtime.reload();
}
reloadImages (path) {
let img;
const expando = this.generateUniqueString();
for (img of Array.from(this.document.images)) {
if (pathsMatch(path, pathFromUrl(img.src))) {
img.src = this.generateCacheBustUrl(img.src, expando);
}
}
if (this.document.querySelectorAll) {
for (const { selector, styleNames } of IMAGE_STYLES) {
for (img of Array.from(this.document.querySelectorAll(`[style*=${selector}]`))) {
this.reloadStyleImages(img.style, styleNames, path, expando);
}
}
}
if (this.document.styleSheets) {
return Array.from(this.document.styleSheets).map(styleSheet =>
this.reloadStylesheetImages(styleSheet, path, expando)
);
}
}
reloadStylesheetImages (styleSheet, path, expando) {
let rules;
try {
rules = (styleSheet || {}).cssRules;
} catch (e) {}
if (!rules) {
return;
}
for (const rule of Array.from(rules)) {
switch (rule.type) {
case CSSRule.IMPORT_RULE:
this.reloadStylesheetImages(rule.styleSheet, path, expando);
break;
case CSSRule.STYLE_RULE:
for (const { styleNames } of IMAGE_STYLES) {
this.reloadStyleImages(rule.style, styleNames, path, expando);
}
break;
case CSSRule.MEDIA_RULE:
this.reloadStylesheetImages(rule, path, expando);
break;
}
}
}
reloadStyleImages (style, styleNames, path, expando) {
for (const styleName of styleNames) {
const value = style[styleName];
if (typeof value === 'string') {
// eslint-disable-next-line prefer-regex-literals
const newValue = value.replace(new RegExp('\\burl\\s*\\(([^)]*)\\)'), (match, src) => {
if (pathsMatch(path, pathFromUrl(src))) {
return `url(${this.generateCacheBustUrl(src, expando)})`;
}
return match;
});
if (newValue !== value) {
style[styleName] = newValue;
}
}
}
}
reloadStylesheet (path) {
const options = this.options || DEFAULT_OPTIONS;
// has to be a real array, because DOMNodeList will be modified
let style;
let link;
const links = ((() => {
const result = [];
for (link of Array.from(this.document.getElementsByTagName('link'))) {
if (link.rel.match(/^stylesheet$/i) && !link.__LiveReload_pendingRemoval) {
result.push(link);
}
}
return result;
})());
// find all imported stylesheets
const imported = [];
for (style of Array.from(this.document.getElementsByTagName('style'))) {
if (style.sheet) {
this.collectImportedStylesheets(style, style.sheet, imported);
}
}
for (link of Array.from(links)) {
this.collectImportedStylesheets(link, link.sheet, imported);
}
// handle prefixfree
if (this.window.StyleFix && this.document.querySelectorAll) {
for (style of Array.from(this.document.querySelectorAll('style[data-href]'))) {
links.push(style);
}
}
this.console.log(`LiveReload found ${links.length} LINKed stylesheets, ${imported.length} stylesheets`);
const match = pickBestMatch(
path,
links.concat(imported),
link => pathFromUrl(this.linkHref(link))
);
if (match) {
if (match.object.rule) {
this.console.log(`LiveReload is reloading imported stylesheet: ${match.object.href}`);
this.reattachImportedRule(match.object);
} else {
this.console.log(`LiveReload is reloading stylesheet: ${this.linkHref(match.object)}`);
this.reattachStylesheetLink(match.object);
}
} else {
if (options.reloadMissingCSS) {
this.console.log(`LiveReload will reload all stylesheets because path '${path}' did not match any specific one. \
To disable this behavior, set 'options.reloadMissingCSS' to 'false'.`
);
for (link of Array.from(links)) {
this.reattachStylesheetLink(link);
}
} else {
this.console.log(`LiveReload will not reload path '${path}' because the stylesheet was not found on the page \
and 'options.reloadMissingCSS' was set to 'false'.`
);
}
}
return true;
}
collectImportedStylesheets (link, styleSheet, result) {
// in WebKit, styleSheet.cssRules is null for inaccessible stylesheets;
// Firefox/Opera may throw exceptions
let rules;
try {
rules = (styleSheet || {}).cssRules;
} catch (e) {}
if (rules && rules.length) {
for (let index = 0; index < rules.length; index++) {
const rule = rules[index];
switch (rule.type) {
case CSSRule.CHARSET_RULE:
continue; // do nothing
case CSSRule.IMPORT_RULE:
result.push({ link, rule, index, href: rule.href });
this.collectImportedStylesheets(link, rule.styleSheet, result);
break;
default:
break; // import rules can only be preceded by charset rules
}
}
}
}
waitUntilCssLoads (clone, func) {
const options = this.options || DEFAULT_OPTIONS;
let callbackExecuted = false;
const executeCallback = () => {
if (callbackExecuted) {
return;
}
callbackExecuted = true;
return func();
};
// supported by Chrome 19+, Safari 5.2+, Firefox 9+, Opera 9+, IE6+
// http://www.zachleat.com/web/load-css-dynamically/
// http://pieisgood.org/test/script-link-events/
clone.onload = () => {
this.console.log('LiveReload: the new stylesheet has finished loading');
this.knownToSupportCssOnLoad = true;
return executeCallback();
};
if (!this.knownToSupportCssOnLoad) {
// polling
let poll;
(poll = () => {
if (clone.sheet) {
this.console.log('LiveReload is polling until the new CSS finishes loading...');
return executeCallback();
}
return this.Timer.start(50, poll);
})();
}
// fail safe
return this.Timer.start(options.stylesheetReloadTimeout, executeCallback);
}
linkHref (link) {
// prefixfree uses data-href when it turns LINK into STYLE
return link.href || (link.getAttribute && link.getAttribute('data-href'));
}
reattachStylesheetLink (link) {
// ignore LINKs that will be removed by LR soon
let clone;
if (link.__LiveReload_pendingRemoval) {
return;
}
link.__LiveReload_pendingRemoval = true;
if (link.tagName === 'STYLE') {
// prefixfree
clone = this.document.createElement('link');
clone.rel = 'stylesheet';
clone.media = link.media;
clone.disabled = link.disabled;
} else {
clone = link.cloneNode(false);
}
clone.href = this.generateCacheBustUrl(this.linkHref(link));
// insert the new LINK before the old one
const parent = link.parentNode;
if (parent.lastChild === link) {
parent.appendChild(clone);
} else {
parent.insertBefore(clone, link.nextSibling);
}
return this.waitUntilCssLoads(clone, () => {
let additionalWaitingTime;
if (/AppleWebKit/.test(this.window.navigator.userAgent)) {
additionalWaitingTime = 5;
} else {
additionalWaitingTime = 200;
}
return this.Timer.start(additionalWaitingTime, () => {
if (!link.parentNode) {
return;
}
link.parentNode.removeChild(link);
clone.onreadystatechange = null;
return (this.window.StyleFix ? this.window.StyleFix.link(clone) : undefined);
});
}); // prefixfree
}
reattachImportedRule ({ rule, index, link }) {
const parent = rule.parentStyleSheet;
const href = this.generateCacheBustUrl(rule.href);
const media = rule.media.length ? [].join.call(rule.media, ', ') : '';
const newRule = ` url("${href}") ${media};`;
// used to detect if reattachImportedRule has been called again on the same rule
rule.__LiveReload_newHref = href;
// WORKAROUND FOR WEBKIT BUG: WebKit resets all styles if we add @import'ed
// stylesheet that hasn't been cached yet. Workaround is to pre-cache the
// stylesheet by temporarily adding it as a LINK tag.
const tempLink = this.document.createElement('link');
tempLink.rel = 'stylesheet';
tempLink.href = href;
tempLink.__LiveReload_pendingRemoval = true; // exclude from path matching
if (link.parentNode) {
link.parentNode.insertBefore(tempLink, link);
}
// wait for it to load
return this.Timer.start(this.importCacheWaitPeriod, () => {
if (tempLink.parentNode) {
tempLink.parentNode.removeChild(tempLink);
}
// if another reattachImportedRule call is in progress, abandon this one
if (rule.__LiveReload_newHref !== href) {
return;
}
parent.insertRule(newRule, index);
parent.deleteRule(index + 1);
// save the new rule, so that we can detect another reattachImportedRule call
rule = parent.cssRules[index];
rule.__LiveReload_newHref = href;
// repeat again for good measure
return this.Timer.start(this.importCacheWaitPeriod, () => {
// if another reattachImportedRule call is in progress, abandon this one
if (rule.__LiveReload_newHref !== href) {
return;
}
parent.insertRule(newRule, index);
return parent.deleteRule(index + 1);
});
});
}
generateUniqueString () {
return `livereload=${Date.now()}`;
}
generateCacheBustUrl (url, expando) {
const options = this.options || DEFAULT_OPTIONS;
let hash, oldParams;
if (!expando) {
expando = this.generateUniqueString();
}
({ url, hash, params: oldParams } = splitUrl(url));
if (options.overrideURL) {
if (url.indexOf(options.serverURL) < 0) {
const originalUrl = url;
url = options.serverURL + options.overrideURL + '?url=' + encodeURIComponent(url);
this.console.log(`LiveReload is overriding source URL ${originalUrl} with ${url}`);
}
}
let params = oldParams.replace(/(\?|&)livereload=(\d+)/, (match, sep) => `${sep}${expando}`);
if (params === oldParams) {
if (oldParams.length === 0) {
params = `?${expando}`;
} else {
params = `${oldParams}&${expando}`;
}
}
return url + params + hash;
}
};
exports.splitUrl = splitUrl;
exports.pathFromUrl = pathFromUrl;
exports.numberOfMatchingSegments = numberOfMatchingSegments;
exports.pickBestMatch = pickBestMatch;
exports.pathsMatch = pathsMatch;
exports.Reloader = Reloader;