@pattern-lab/live-server
Version:
simple development http server with live reload capability
502 lines (443 loc) • 14.4 kB
JavaScript
const fs = require('fs');
const connect = require('connect');
const serveIndex = require('serve-index');
const logger = require('morgan');
const WebSocket = require('faye-websocket');
const path = require('path');
const url = require('url');
const http = require('http');
const send = require('send');
const open = require('open');
const es = require('event-stream');
const os = require('os');
const chokidar = require('chokidar');
require('colors');
const INJECTED_CODE = fs.readFileSync(
path.join(__dirname, 'injected.html'),
'utf8'
);
const LiveServer = {
server: null,
watcher: null,
logLevel: 2,
};
function escape(html) {
return String(html)
.replace(/&(?!\w+;)/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// Based on connect.static(), but streamlined and with added code injecter
function staticServer(root) {
let isFile = false;
try {
// For supporting mounting files instead of just directories
isFile = fs.statSync(root).isFile();
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
return function (req, res, next) {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const reqpath = isFile ? '' : url.parse(req.url).pathname;
const hasNoOrigin = !req.headers.origin;
const injectCandidates = [
new RegExp('</body>', 'i'),
new RegExp('</head>', 'i'),
];
let injectTag = null;
let injectCount = 0;
function directory() {
const pathname = url.parse(req.originalUrl).pathname;
res.statusCode = 301;
res.setHeader('Location', pathname + '/');
res.end('Redirecting to ' + escape(pathname) + '/');
}
function file(filepath /*, stat*/) {
const x = path.extname(filepath).toLocaleLowerCase();
const possibleExtensions = [
'',
'.html',
'.htm',
'.xhtml',
'.php',
'.svg',
];
let matches;
if (hasNoOrigin && possibleExtensions.indexOf(x) > -1) {
// TODO: Sync file read here is not nice, but we need to determine if the html should be injected or not
const contents = fs.readFileSync(filepath, 'utf8');
for (let i = 0; i < injectCandidates.length; ++i) {
matches = contents.match(injectCandidates[i]);
injectCount = (matches && matches.length) || 0;
if (injectCount) {
injectTag = matches[0];
break;
}
}
if (injectTag === null && LiveServer.logLevel >= 3) {
console.warn(
'Failed to inject refresh script!'.yellow,
"Couldn't find any of the tags ",
injectCandidates,
'from',
filepath
);
}
}
}
function error(err) {
if (err.status === 404) return next();
return next(err);
}
function inject(stream) {
if (injectTag) {
// We need to modify the length given to browser
const len =
INJECTED_CODE.length * injectCount + res.getHeader('Content-Length');
res.setHeader('Content-Length', len);
const originalPipe = stream.pipe;
stream.pipe = function (resp) {
originalPipe
.call(
stream,
es.replace(new RegExp(injectTag, 'i'), INJECTED_CODE + injectTag)
)
.pipe(resp);
};
}
}
return send(req, reqpath, { root: root })
.on('error', error)
.on('directory', directory)
.on('file', file)
.on('stream', inject)
.pipe(res);
};
}
/**
* Rewrite request URL and pass it back to the static handler.
* @param staticHandler {function} Next handler
* @param file {string} Path to the entry point file
*/
function entryPoint(staticHandler, file) {
if (!file)
return function (req, res, next) {
next();
};
return function (req, res, next) {
req.url = '/' + file;
staticHandler(req, res, next);
};
}
/**
* Start a live server with parameters given as an object
* @param host {string} Address to bind to (default: 0.0.0.0)
* @param port {number} Port number (default: 8080)
* @param root {string} Path to root directory (default: cwd)
* @param watch {array} Paths to exclusively watch for changes
* @param ignore {array} Paths to ignore when watching files for changes
* @param ignorePattern {regexp} Ignore files by RegExp
* @param noCssInject Don't inject CSS changes, just reload as with any other file change
* @param open {(string|string[])} Subpath(s) to open in browser, use false to suppress launch (default: server root)
* @param mount {array} Mount directories onto a route, e.g. [['/components', './node_modules']].
* @param logLevel {number} 0 = errors only, 1 = some, 2 = lots
* @param file {string} Path to the entry point file
* @param wait {number} Server will wait for all changes, before reloading
* @param htpasswd {string} Path to htpasswd file to enable HTTP Basic authentication
* @param middleware {array} Append middleware to stack, e.g. [function(req, res, next) { next(); }].
* @param assets {String[]} path of asset directories to watch
*/
LiveServer.start = function (options) {
const host = options.host || '0.0.0.0';
const port = options.port !== undefined ? options.port : 8080; // 0 means random
const root = options.root || process.cwd();
const mount = options.mount || [];
const watchPaths =
options.watch || (options.assets ? [root, ...options.assets] : [root]);
LiveServer.logLevel = options.logLevel === undefined ? 2 : options.logLevel;
let openPath =
options.open === undefined || options.open === true
? ''
: options.open === null || options.open === false
? null
: options.open;
if (options.noBrowser) openPath = null; // Backwards compatibility with 0.7.0
const file = options.file;
const staticServerHandler = staticServer(root);
const wait = options.wait === undefined ? 100 : options.wait;
const browser = options.browser || null;
const htpasswd = options.htpasswd || null;
const cors = options.cors || false;
const https = options.https || null;
const proxy = options.proxy || [];
const middleware = options.middleware || [];
const noCssInject = options.noCssInject;
let httpsModule = options.httpsModule;
if (httpsModule) {
try {
require.resolve(httpsModule);
} catch (e) {
console.error(
`HTTPS module "${httpsModule}" you've provided was not found.`.red
);
console.error('Did you do', `"npm install ${httpsModule}"?`);
return;
}
} else {
httpsModule = 'https';
}
// Setup a web server
const app = connect();
// Add logger. Level 2 logs only errors
if (LiveServer.logLevel === 2) {
app.use(
logger('dev', {
skip: function (req, res) {
return res.statusCode < 400;
},
})
);
// Level 2 or above logs all requests
} else if (LiveServer.logLevel > 2) {
app.use(logger('dev'));
}
if (options.spa) {
middleware.push('spa');
}
// Add middleware
middleware.map((mw) => {
let mwm = mw;
if (typeof mw === 'string') {
if (path.extname(mw).toLocaleLowerCase() !== '.js') {
mwm = require(path.join(__dirname, 'middleware', mw + '.js'));
} else {
mwm = require(mw);
}
}
app.use(mwm);
});
// Use http-auth if configured
if (htpasswd !== null) {
const auth = require('http-auth');
const authConnect = require('http-auth-connect');
const basic = auth.basic({
realm: 'Please authorize',
file: htpasswd,
});
app.use(authConnect(basic));
}
if (cors) {
app.use(
require('cors')({
origin: true, // reflecting request origin
credentials: true, // allowing requests with credentials
})
);
}
mount.forEach((mountRule) => {
const mountPath = path.resolve(process.cwd(), mountRule[1]);
if (!options.watch) {
// Auto add mount paths to wathing but only if exclusive path option is not given
watchPaths.push(mountPath);
}
app.use(mountRule[0], staticServer(mountPath));
if (LiveServer.logLevel >= 1) {
console.log('Mapping %s to "%s"', mountRule[0], mountPath);
}
});
proxy.forEach((proxyRule) => {
const proxyOpts = url.parse(proxyRule[1]);
proxyOpts.via = true;
proxyOpts.preserveHost = true;
app.use(proxyRule[0], require('proxy-middleware')(proxyOpts));
if (LiveServer.logLevel >= 1) {
console.log('Mapping %s to "%s"', proxyRule[0], proxyRule[1]);
}
});
app
.use(staticServerHandler) // Custom static server
.use(entryPoint(staticServerHandler, file))
.use(serveIndex(root, { icons: true }));
let server, protocol;
if (https !== null) {
let httpsConfig = https;
if (typeof https === 'string') {
httpsConfig = require(path.resolve(process.cwd(), https));
}
server = require(httpsModule).createServer(httpsConfig, app);
protocol = 'https';
} else {
server = http.createServer(app);
protocol = 'http';
}
// Handle server startup errors
server.addListener('error', function (e) {
if (e.code === 'EADDRINUSE') {
console.log(
'%s is already in use. Trying another port.'.yellow,
`${protocol}://${host}:${port}`
);
setTimeout(function () {
server.listen(0, host);
}, 1000);
} else {
console.error(e.toString().red);
LiveServer.shutdown();
}
});
// Handle successful server
server.addListener('listening', function (/*e*/) {
LiveServer.server = server;
const address = server.address();
const serveHost =
address.address === '0.0.0.0' ? '127.0.0.1' : address.address;
const openHost = host === '0.0.0.0' ? '127.0.0.1' : host;
const serveURL = `${protocol}://${serveHost}:${address.port}`;
const openURL = `${protocol}://${openHost}:${address.port}`;
let serveURLs = [serveURL];
if (LiveServer.logLevel > 2 && address.address === '0.0.0.0') {
const ifaces = os.networkInterfaces();
serveURLs = Object.keys(ifaces)
.map((iface) => ifaces[iface])
// flatten address data, use only IPv4
.reduce((data, addresses) => {
addresses
.filter((addr) => addr.family === 'IPv4')
.forEach((addr) => data.push(addr));
return data;
}, [])
.map((addr) => `${protocol}://${addr.address}:${address.port}`);
}
// Output
if (LiveServer.logLevel >= 1) {
if (serveURL === openURL)
if (serveURLs.length === 1) {
console.log('Serving "%s" at %s'.green, root, serveURLs[0]);
} else {
console.log(
'Serving "%s" at\n\t%s'.green,
root,
serveURLs.join('\n\t')
);
}
else
console.log('Serving "%s" at %s (%s)'.green, root, openURL, serveURL);
}
// Launch browser
if (openPath !== null)
if (typeof openPath === 'object') {
openPath.forEach((p) =>
open(openURL + p, { app: { name: browser } }).catch(() =>
console.log(
'Warning: Could not open pattern lab in default browser.'
)
)
);
} else {
open(openURL + openPath, { app: { name: browser } }).catch(() =>
console.log('Warning: Could not open pattern lab in default browser.')
);
}
});
// Setup server to listen at port
server.listen(port, host);
// WebSocket
let clients = [];
server.addListener('upgrade', function (request, socket, head) {
const ws = new WebSocket(request, socket, head);
ws.onopen = function () {
ws.send('connected');
};
if (wait > 0) {
(function () {
const wssend = ws.send;
let waitTimeout;
ws.send = function () {
const args = arguments;
if (waitTimeout) clearTimeout(waitTimeout);
waitTimeout = setTimeout(function () {
wssend.apply(ws, args);
}, wait);
};
})();
}
ws.onclose = function () {
clients = clients.filter((x) => x !== ws);
};
clients.push(ws);
});
let ignored = [
function (testPath) {
// Always ignore dotfiles (important e.g. because editor hidden temp files)
return (
testPath !== '.' && /(^[.#]|(?:__|~)$)/.test(path.basename(testPath))
);
},
];
if (options.ignore) {
ignored = ignored.concat(options.ignore);
}
if (options.ignorePattern) {
ignored.push(options.ignorePattern);
}
// Setup file watcher
LiveServer.watcher = chokidar.watch(
// Replace backslashes with slashes, because chokidar pattern
// like path/**/*.xyz only acceps linux file path
watchPaths.map((p) => p.replace(/\\/g, '/')),
{
ignored: ignored,
ignoreInitial: true,
awaitWriteFinish: true,
}
);
function handleChange(changePath) {
const cssChange = path.extname(changePath) === '.css' && !noCssInject;
if (LiveServer.logLevel >= 1) {
if (cssChange) console.log('CSS change detected'.magenta, changePath);
else console.log('Change detected'.cyan, changePath);
}
clients.forEach((ws) => {
if (ws) ws.send(cssChange ? 'refreshcss' : 'reload');
});
}
LiveServer.watcher
.on('change', handleChange)
.on('add', handleChange)
.on('unlink', handleChange)
.on('addDir', handleChange)
.on('unlinkDir', handleChange)
.on('ready', function () {
if (LiveServer.logLevel >= 1) console.log('Ready for changes'.cyan);
})
.on('error', function (err) {
console.log('ERROR:'.red, err);
});
LiveServer.refreshCSS = function () {
if (clients.length) {
clients.forEach((ws) => {
if (ws) ws.send('refreshcss');
});
}
};
LiveServer.reload = function () {
if (clients.length) {
clients.forEach((ws) => {
if (ws) ws.send('reload');
});
}
};
// server needs to get returned for the tests
return server; // eslint-disable-line consistent-return
};
LiveServer.shutdown = function () {
if (LiveServer.watcher) {
LiveServer.watcher.close();
}
if (LiveServer.server) {
LiveServer.server.close();
}
};
module.exports = LiveServer;