469 lines
12 KiB
JavaScript
469 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
var fs = require("fs"),
|
|
connect = require("connect"),
|
|
serveIndex = require("serve-index"),
|
|
logger = require("morgan"),
|
|
WebSocket = require("faye-websocket"),
|
|
path = require("path"),
|
|
url = require("url"),
|
|
http = require("http"),
|
|
send = require("send"),
|
|
open = require("open"),
|
|
es = require("event-stream"),
|
|
os = require("os"),
|
|
chokidar = require("chokidar");
|
|
require("colors");
|
|
|
|
var INJECTED_CODE = fs.readFileSync(
|
|
path.join(__dirname, "injected.html"),
|
|
"utf8"
|
|
);
|
|
|
|
var 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) {
|
|
var 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();
|
|
var reqpath = isFile ? "" : url.parse(req.url).pathname;
|
|
var hasNoOrigin = !req.headers.origin;
|
|
var injectCandidates = [
|
|
new RegExp("</body>", "i"),
|
|
new RegExp("</svg>"),
|
|
new RegExp("</head>", "i"),
|
|
];
|
|
var injectTag = null;
|
|
|
|
function directory() {
|
|
var pathname = url.parse(req.originalUrl).pathname;
|
|
res.statusCode = 301;
|
|
res.setHeader("Location", pathname + "/");
|
|
res.end("Redirecting to " + escape(pathname) + "/");
|
|
}
|
|
|
|
function file(filepath /*, stat*/) {
|
|
var x = path.extname(filepath).toLocaleLowerCase(),
|
|
match,
|
|
possibleExtensions = [
|
|
"",
|
|
".html",
|
|
".htm",
|
|
".xhtml",
|
|
".php",
|
|
".svg",
|
|
];
|
|
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
|
|
var contents = fs.readFileSync(filepath, "utf8");
|
|
for (var i = 0; i < injectCandidates.length; ++i) {
|
|
match = injectCandidates[i].exec(contents);
|
|
if (match) {
|
|
injectTag = match[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();
|
|
next(err);
|
|
}
|
|
|
|
function inject(stream) {
|
|
if (injectTag) {
|
|
// We need to modify the length given to browser
|
|
var len =
|
|
INJECTED_CODE.length + res.getHeader("Content-Length");
|
|
res.setHeader("Content-Length", len);
|
|
var originalPipe = stream.pipe;
|
|
stream.pipe = function (resp) {
|
|
originalPipe
|
|
.call(
|
|
stream,
|
|
es.replace(
|
|
new RegExp(injectTag, "i"),
|
|
INJECTED_CODE + injectTag
|
|
)
|
|
)
|
|
.pipe(resp);
|
|
};
|
|
}
|
|
}
|
|
|
|
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(); }].
|
|
*/
|
|
LiveServer.start = function (options) {
|
|
options = options || {};
|
|
var host = options.host || "0.0.0.0";
|
|
var port = options.port !== undefined ? options.port : 8080; // 0 means random
|
|
var root = options.root || process.cwd();
|
|
var mount = options.mount || [];
|
|
var watchPaths = options.watch || [root];
|
|
LiveServer.logLevel = options.logLevel === undefined ? 2 : options.logLevel;
|
|
var 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
|
|
var file = options.file;
|
|
var staticServerHandler = staticServer(root);
|
|
var wait = options.wait === undefined ? 100 : options.wait;
|
|
var browser = options.browser || null;
|
|
var htpasswd = options.htpasswd || null;
|
|
var cors = options.cors || false;
|
|
var https = options.https || null;
|
|
var proxy = options.proxy || [];
|
|
var middleware = options.middleware || [];
|
|
var noCssInject = options.noCssInject;
|
|
var 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
|
|
var 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(function (mw) {
|
|
if (typeof mw === "string") {
|
|
var ext = path.extname(mw).toLocaleLowerCase();
|
|
if (ext !== ".js") {
|
|
mw = require(path.join(__dirname, "middleware", mw + ".js"));
|
|
} else {
|
|
mw = require(mw);
|
|
}
|
|
}
|
|
app.use(mw);
|
|
});
|
|
|
|
// Use http-auth if configured
|
|
if (htpasswd !== null) {
|
|
var auth = require("http-auth");
|
|
var authConnect = require("http-auth-connect");
|
|
var 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(function (mountRule) {
|
|
var 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(function (proxyRule) {
|
|
var 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 }));
|
|
|
|
var server, protocol;
|
|
if (https !== null) {
|
|
var 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") {
|
|
var serveURL = protocol + "://" + host + ":" + port;
|
|
console.log(
|
|
"%s is already in use. Trying another port.".yellow,
|
|
serveURL
|
|
);
|
|
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;
|
|
|
|
var address = server.address();
|
|
var serveHost =
|
|
address.address === "0.0.0.0" ? "127.0.0.1" : address.address;
|
|
var openHost = host === "0.0.0.0" ? "127.0.0.1" : host;
|
|
|
|
var serveURL = protocol + "://" + serveHost + ":" + address.port;
|
|
var openURL = protocol + "://" + openHost + ":" + address.port;
|
|
|
|
var serveURLs = [serveURL];
|
|
if (LiveServer.logLevel > 2 && address.address === "0.0.0.0") {
|
|
var ifaces = os.networkInterfaces();
|
|
serveURLs = Object.keys(ifaces)
|
|
.map(function (iface) {
|
|
return ifaces[iface];
|
|
})
|
|
// flatten address data, use only IPv4
|
|
.reduce(function (data, addresses) {
|
|
addresses
|
|
.filter(function (addr) {
|
|
return addr.family === "IPv4";
|
|
})
|
|
.forEach(function (addr) {
|
|
data.push(addr);
|
|
});
|
|
return data;
|
|
}, [])
|
|
.map(function (addr) {
|
|
return 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(function (p) {
|
|
open(openURL + p, { app: browser });
|
|
});
|
|
} else {
|
|
open(openURL + openPath, { app: browser });
|
|
}
|
|
});
|
|
|
|
// Setup server to listen at port
|
|
server.listen(port, host);
|
|
|
|
// WebSocket
|
|
var clients = [];
|
|
server.addListener("upgrade", function (request, socket, head) {
|
|
var ws = new WebSocket(request, socket, head);
|
|
ws.onopen = function () {
|
|
ws.send("connected");
|
|
};
|
|
|
|
if (wait > 0) {
|
|
(function () {
|
|
var wssend = ws.send;
|
|
var waitTimeout;
|
|
ws.send = function () {
|
|
var args = arguments;
|
|
if (waitTimeout) clearTimeout(waitTimeout);
|
|
waitTimeout = setTimeout(function () {
|
|
wssend.apply(ws, args);
|
|
}, wait);
|
|
};
|
|
})();
|
|
}
|
|
|
|
ws.onclose = function () {
|
|
clients = clients.filter(function (x) {
|
|
return x !== ws;
|
|
});
|
|
};
|
|
|
|
clients.push(ws);
|
|
});
|
|
|
|
var 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(watchPaths, {
|
|
ignored: ignored,
|
|
ignoreInitial: true,
|
|
});
|
|
function handleChange(changePath) {
|
|
var 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(function (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);
|
|
});
|
|
|
|
return server;
|
|
};
|
|
|
|
LiveServer.shutdown = function () {
|
|
var watcher = LiveServer.watcher;
|
|
if (watcher) {
|
|
watcher.close();
|
|
}
|
|
var server = LiveServer.server;
|
|
if (server) server.close();
|
|
};
|
|
|
|
module.exports = LiveServer;
|