lib/tasks/server/livereload-server.js
'use strict';
const SilentError = require('silent-error');
const walkSync = require('walk-sync');
const path = require('path');
const FSTree = require('fs-tree-diff');
const logger = require('heimdalljs-logger')('ember-cli:live-reload:');
const fs = require('fs');
const cleanBaseUrl = require('clean-base-url');
const isLiveReloadRequest = require('../../utilities/is-live-reload-request');
function isNotRemoved(entryTuple) {
let operation = entryTuple[0];
return operation !== 'unlink' && operation !== 'rmdir';
}
function isNotDirectory(entryTuple) {
let entry = entryTuple[2];
return entry && !entry.isDirectory();
}
function relativePath(patch) {
return patch[1];
}
function isNotSourceMapFile(file) {
return !/\.map$/.test(file);
}
function readSSLandCert(sslKey, sslCert) {
if (!fs.existsSync(sslKey)) {
throw new TypeError(
`SSL key couldn't be found in "${sslKey}", ` + `please provide a path to an existing ssl key file with --ssl-key`
);
}
if (!fs.existsSync(sslCert)) {
throw new TypeError(
`SSL certificate couldn't be found in "${sslCert}", ` +
`please provide a path to an existing ssl certificate file with --ssl-cert`
);
}
let key = fs.readFileSync(sslKey);
let cert = fs.readFileSync(sslCert);
return { key, cert };
}
const DEFAULT_PREFIX = '/';
module.exports = class LiveReloadServer {
constructor({ app, watcher, ui, project, httpServer }) {
this.app = app;
this.watcher = watcher;
this.ui = ui;
this.project = project;
this.httpServer = httpServer;
this.liveReloadPrefix = DEFAULT_PREFIX;
}
setupMiddleware(options) {
const tinylr = require('tiny-lr');
const Server = tinylr.Server;
if (options.liveReloadPrefix) {
this.liveReloadPrefix = cleanBaseUrl(options.liveReloadPrefix);
}
if (options.liveReload) {
if (options.liveReloadPort && options.port !== options.liveReloadPort) {
return this.createServerforCustomPort(options, Server).catch((error) => {
if (error !== null && typeof error === 'object' && error.code === 'EADDRINUSE') {
let url = `http${options.ssl ? 's' : ''}://${this.displayHost(options.liveReloadHost)}:${
options.liveReloadPort
}`;
throw new SilentError(`${error.message}\nLivereload failed on '${url}', It may be in use.`);
} else {
throw error;
}
});
} else {
this.liveReloadServer = this.createServer(options, Server);
}
this.start();
} else {
this.ui.writeWarnLine('Livereload server manually disabled.');
}
return Promise.resolve();
}
start() {
this.tree = FSTree.fromEntries([]);
// Reload on file changes
this.watcher.on(
'change',
function () {
try {
this.didChange.apply(this, arguments);
} catch (e) {
this.ui.writeError(e);
}
}.bind(this)
);
this.watcher.on('error', this.didChange.bind(this));
// Reload on express server restarts
this.app.on('restart', this.didRestart.bind(this));
this.httpServer.on('upgrade', (req, socket, head) => {
if (isLiveReloadRequest(req.url, this.liveReloadPrefix)) {
this.liveReloadServer.websocketify(req, socket, head);
}
});
this.httpServer.on('error', this.liveReloadServer.error.bind(this.liveReloadServer));
this.httpServer.on('close', this.liveReloadServer.close.bind(this.liveReloadServer));
this.app.use(this.liveReloadPrefix, this.liveReloadServer.handler.bind(this.liveReloadServer));
}
createServer(options, Server) {
let serverOptions = {
app: this.app,
dashboard: 'false',
prefix: DEFAULT_PREFIX,
port: options.port,
};
if (options.ssl) {
let { key, cert } = readSSLandCert(options.sslKey, options.sslCert);
serverOptions.key = key;
serverOptions.cert = cert;
}
let lrServer = new Server(serverOptions);
// this is required to prevent tiny-lr from triggering an error
// when checking this.server._handle during its close handler
// here: https://github.com/mklabs/tiny-lr/blob/d68d983eaf80b5bae78b2dba259a1ad5e3b03a63/lib/server.js#L209
lrServer.server = this.httpServer;
return lrServer;
}
createServerforCustomPort(options, Server) {
let instance;
Server.prototype.error = function () {
instance.error.apply(instance, arguments);
};
let serverOptions = {
dashboard: 'false',
prefix: options.liveReloadPrefix,
port: options.liveReloadPort,
host: options.liveReloadHost,
};
if (options.ssl) {
let { key, cert } = readSSLandCert(options.sslKey, options.sslCert);
serverOptions.key = key;
serverOptions.cert = cert;
}
instance = new Server(serverOptions);
this.liveReloadServer = instance;
this.start();
return new Promise((resolve, reject) => {
this.liveReloadServer.error = reject;
this.liveReloadServer.listen(options.liveReloadPort, options.liveReloadHost, resolve);
});
}
displayHost(specifiedHost) {
return specifiedHost || 'localhost';
}
writeSkipBanner(filePath) {
this.ui.writeLine(`Skipping livereload for: ${filePath}`);
}
getDirectoryEntries(directory) {
return walkSync.entries(directory);
}
shouldTriggerReload(options) {
let result = true;
if (this.project.liveReloadFilterPatterns.length > 0) {
let filePath = path.relative(this.project.root, options.filePath || '');
result = this.project.liveReloadFilterPatterns.every((pattern) => pattern.test(filePath) === false);
if (result === false) {
this.writeSkipBanner(filePath);
}
}
return result;
}
didChange(results) {
let previousTree = this.tree;
let files;
if (results.stack) {
this._hasCompileError = true;
files = ['LiveReload due to compile error'];
} else if (this._hasCompileError) {
this._hasCompileError = false;
files = ['LiveReload due to resolved compile error'];
} else if (results.directory) {
this.tree = FSTree.fromEntries(this.getDirectoryEntries(results.directory), { sortAndExpand: true });
files = previousTree
.calculatePatch(this.tree)
.filter(isNotRemoved)
.filter(isNotDirectory)
.map(relativePath)
.filter(isNotSourceMapFile);
} else {
files = ['LiveReload files'];
}
logger.info('files %o', files);
if (this.shouldTriggerReload(results)) {
this.liveReloadServer.changed({
body: {
files,
},
});
}
}
didRestart() {
this.liveReloadServer.changed({
body: {
files: ['LiveReload files'],
},
});
}
};