index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. #!/usr/bin/env node
  2. var fs = require("fs"),
  3. connect = require("connect"),
  4. serveIndex = require("serve-index"),
  5. logger = require("morgan"),
  6. WebSocket = require("faye-websocket"),
  7. path = require("path"),
  8. url = require("url"),
  9. http = require("http"),
  10. send = require("send"),
  11. open = require("open"),
  12. es = require("event-stream"),
  13. os = require("os"),
  14. chokidar = require("chokidar");
  15. require("colors");
  16. var INJECTED_CODE = fs.readFileSync(
  17. path.join(__dirname, "injected.html"),
  18. "utf8"
  19. );
  20. var LiveServer = {
  21. server: null,
  22. watcher: null,
  23. logLevel: 2,
  24. };
  25. function escape(html) {
  26. return String(html)
  27. .replace(/&(?!\w+;)/g, "&")
  28. .replace(/</g, "&lt;")
  29. .replace(/>/g, "&gt;")
  30. .replace(/"/g, "&quot;");
  31. }
  32. // Based on connect.static(), but streamlined and with added code injecter
  33. function staticServer(root) {
  34. var isFile = false;
  35. try {
  36. // For supporting mounting files instead of just directories
  37. isFile = fs.statSync(root).isFile();
  38. } catch (e) {
  39. if (e.code !== "ENOENT") throw e;
  40. }
  41. return function (req, res, next) {
  42. if (req.method !== "GET" && req.method !== "HEAD") return next();
  43. var reqpath = isFile ? "" : url.parse(req.url).pathname;
  44. var hasNoOrigin = !req.headers.origin;
  45. var injectCandidates = [
  46. new RegExp("</body>", "i"),
  47. new RegExp("</svg>"),
  48. new RegExp("</head>", "i"),
  49. ];
  50. var injectTag = null;
  51. function directory() {
  52. var pathname = url.parse(req.originalUrl).pathname;
  53. res.statusCode = 301;
  54. res.setHeader("Location", pathname + "/");
  55. res.end("Redirecting to " + escape(pathname) + "/");
  56. }
  57. function file(filepath /*, stat*/) {
  58. var x = path.extname(filepath).toLocaleLowerCase(),
  59. match,
  60. possibleExtensions = [
  61. "",
  62. ".html",
  63. ".htm",
  64. ".xhtml",
  65. ".php",
  66. ".svg",
  67. ];
  68. if (hasNoOrigin && possibleExtensions.indexOf(x) > -1) {
  69. // TODO: Sync file read here is not nice, but we need to determine if the html should be injected or not
  70. var contents = fs.readFileSync(filepath, "utf8");
  71. for (var i = 0; i < injectCandidates.length; ++i) {
  72. match = injectCandidates[i].exec(contents);
  73. if (match) {
  74. injectTag = match[0];
  75. break;
  76. }
  77. }
  78. if (injectTag === null && LiveServer.logLevel >= 3) {
  79. console.warn(
  80. "Failed to inject refresh script!".yellow,
  81. "Couldn't find any of the tags ",
  82. injectCandidates,
  83. "from",
  84. filepath
  85. );
  86. }
  87. }
  88. }
  89. function error(err) {
  90. if (err.status === 404) return next();
  91. next(err);
  92. }
  93. function inject(stream) {
  94. if (injectTag) {
  95. // We need to modify the length given to browser
  96. var len =
  97. INJECTED_CODE.length + res.getHeader("Content-Length");
  98. res.setHeader("Content-Length", len);
  99. var originalPipe = stream.pipe;
  100. stream.pipe = function (resp) {
  101. originalPipe
  102. .call(
  103. stream,
  104. es.replace(
  105. new RegExp(injectTag, "i"),
  106. INJECTED_CODE + injectTag
  107. )
  108. )
  109. .pipe(resp);
  110. };
  111. }
  112. }
  113. send(req, reqpath, { root: root })
  114. .on("error", error)
  115. .on("directory", directory)
  116. .on("file", file)
  117. .on("stream", inject)
  118. .pipe(res);
  119. };
  120. }
  121. /**
  122. * Rewrite request URL and pass it back to the static handler.
  123. * @param staticHandler {function} Next handler
  124. * @param file {string} Path to the entry point file
  125. */
  126. function entryPoint(staticHandler, file) {
  127. if (!file)
  128. return function (req, res, next) {
  129. next();
  130. };
  131. return function (req, res, next) {
  132. req.url = "/" + file;
  133. staticHandler(req, res, next);
  134. };
  135. }
  136. /**
  137. * Start a live server with parameters given as an object
  138. * @param host {string} Address to bind to (default: 0.0.0.0)
  139. * @param port {number} Port number (default: 8080)
  140. * @param root {string} Path to root directory (default: cwd)
  141. * @param watch {array} Paths to exclusively watch for changes
  142. * @param ignore {array} Paths to ignore when watching files for changes
  143. * @param ignorePattern {regexp} Ignore files by RegExp
  144. * @param noCssInject Don't inject CSS changes, just reload as with any other file change
  145. * @param open {(string|string[])} Subpath(s) to open in browser, use false to suppress launch (default: server root)
  146. * @param mount {array} Mount directories onto a route, e.g. [['/components', './node_modules']].
  147. * @param logLevel {number} 0 = errors only, 1 = some, 2 = lots
  148. * @param file {string} Path to the entry point file
  149. * @param wait {number} Server will wait for all changes, before reloading
  150. * @param htpasswd {string} Path to htpasswd file to enable HTTP Basic authentication
  151. * @param middleware {array} Append middleware to stack, e.g. [function(req, res, next) { next(); }].
  152. */
  153. LiveServer.start = function (options) {
  154. options = options || {};
  155. var host = options.host || "0.0.0.0";
  156. var port = options.port !== undefined ? options.port : 8080; // 0 means random
  157. var root = options.root || process.cwd();
  158. var mount = options.mount || [];
  159. var watchPaths = options.watch || [root];
  160. LiveServer.logLevel = options.logLevel === undefined ? 2 : options.logLevel;
  161. var openPath =
  162. options.open === undefined || options.open === true
  163. ? ""
  164. : options.open === null || options.open === false
  165. ? null
  166. : options.open;
  167. if (options.noBrowser) openPath = null; // Backwards compatibility with 0.7.0
  168. var file = options.file;
  169. var staticServerHandler = staticServer(root);
  170. var wait = options.wait === undefined ? 100 : options.wait;
  171. var browser = options.browser || null;
  172. var htpasswd = options.htpasswd || null;
  173. var cors = options.cors || false;
  174. var https = options.https || null;
  175. var proxy = options.proxy || [];
  176. var middleware = options.middleware || [];
  177. var noCssInject = options.noCssInject;
  178. var httpsModule = options.httpsModule;
  179. if (httpsModule) {
  180. try {
  181. require.resolve(httpsModule);
  182. } catch (e) {
  183. console.error(
  184. (
  185. 'HTTPS module "' +
  186. httpsModule +
  187. "\" you've provided was not found."
  188. ).red
  189. );
  190. console.error("Did you do", '"npm install ' + httpsModule + '"?');
  191. return;
  192. }
  193. } else {
  194. httpsModule = "https";
  195. }
  196. // Setup a web server
  197. var app = connect();
  198. // Add logger. Level 2 logs only errors
  199. if (LiveServer.logLevel === 2) {
  200. app.use(
  201. logger("dev", {
  202. skip: function (req, res) {
  203. return res.statusCode < 400;
  204. },
  205. })
  206. );
  207. // Level 2 or above logs all requests
  208. } else if (LiveServer.logLevel > 2) {
  209. app.use(logger("dev"));
  210. }
  211. if (options.spa) {
  212. middleware.push("spa");
  213. }
  214. // Add middleware
  215. middleware.map(function (mw) {
  216. if (typeof mw === "string") {
  217. var ext = path.extname(mw).toLocaleLowerCase();
  218. if (ext !== ".js") {
  219. mw = require(path.join(__dirname, "middleware", mw + ".js"));
  220. } else {
  221. mw = require(mw);
  222. }
  223. }
  224. app.use(mw);
  225. });
  226. // Use http-auth if configured
  227. if (htpasswd !== null) {
  228. var auth = require("http-auth");
  229. var authConnect = require("http-auth-connect");
  230. var basic = auth.basic({
  231. realm: "Please authorize",
  232. file: htpasswd,
  233. });
  234. app.use(authConnect(basic));
  235. }
  236. if (cors) {
  237. app.use(
  238. require("cors")({
  239. origin: true, // reflecting request origin
  240. credentials: true, // allowing requests with credentials
  241. })
  242. );
  243. }
  244. mount.forEach(function (mountRule) {
  245. var mountPath = path.resolve(process.cwd(), mountRule[1]);
  246. if (!options.watch)
  247. // Auto add mount paths to wathing but only if exclusive path option is not given
  248. watchPaths.push(mountPath);
  249. app.use(mountRule[0], staticServer(mountPath));
  250. if (LiveServer.logLevel >= 1)
  251. console.log('Mapping %s to "%s"', mountRule[0], mountPath);
  252. });
  253. proxy.forEach(function (proxyRule) {
  254. var proxyOpts = url.parse(proxyRule[1]);
  255. proxyOpts.via = true;
  256. proxyOpts.preserveHost = true;
  257. app.use(proxyRule[0], require("proxy-middleware")(proxyOpts));
  258. if (LiveServer.logLevel >= 1)
  259. console.log('Mapping %s to "%s"', proxyRule[0], proxyRule[1]);
  260. });
  261. app.use(staticServerHandler) // Custom static server
  262. .use(entryPoint(staticServerHandler, file))
  263. .use(serveIndex(root, { icons: true }));
  264. var server, protocol;
  265. if (https !== null) {
  266. var httpsConfig = https;
  267. if (typeof https === "string") {
  268. httpsConfig = require(path.resolve(process.cwd(), https));
  269. }
  270. server = require(httpsModule).createServer(httpsConfig, app);
  271. protocol = "https";
  272. } else {
  273. server = http.createServer(app);
  274. protocol = "http";
  275. }
  276. // Handle server startup errors
  277. server.addListener("error", function (e) {
  278. if (e.code === "EADDRINUSE") {
  279. var serveURL = protocol + "://" + host + ":" + port;
  280. console.log(
  281. "%s is already in use. Trying another port.".yellow,
  282. serveURL
  283. );
  284. setTimeout(function () {
  285. server.listen(0, host);
  286. }, 1000);
  287. } else {
  288. console.error(e.toString().red);
  289. LiveServer.shutdown();
  290. }
  291. });
  292. // Handle successful server
  293. server.addListener("listening", function (/*e*/) {
  294. LiveServer.server = server;
  295. var address = server.address();
  296. var serveHost =
  297. address.address === "0.0.0.0" ? "127.0.0.1" : address.address;
  298. var openHost = host === "0.0.0.0" ? "127.0.0.1" : host;
  299. var serveURL = protocol + "://" + serveHost + ":" + address.port;
  300. var openURL = protocol + "://" + openHost + ":" + address.port;
  301. var serveURLs = [serveURL];
  302. if (LiveServer.logLevel > 2 && address.address === "0.0.0.0") {
  303. var ifaces = os.networkInterfaces();
  304. serveURLs = Object.keys(ifaces)
  305. .map(function (iface) {
  306. return ifaces[iface];
  307. })
  308. // flatten address data, use only IPv4
  309. .reduce(function (data, addresses) {
  310. addresses
  311. .filter(function (addr) {
  312. return addr.family === "IPv4";
  313. })
  314. .forEach(function (addr) {
  315. data.push(addr);
  316. });
  317. return data;
  318. }, [])
  319. .map(function (addr) {
  320. return protocol + "://" + addr.address + ":" + address.port;
  321. });
  322. }
  323. // Output
  324. if (LiveServer.logLevel >= 1) {
  325. if (serveURL === openURL)
  326. if (serveURLs.length === 1) {
  327. console.log('Serving "%s" at %s'.green, root, serveURLs[0]);
  328. } else {
  329. console.log(
  330. 'Serving "%s" at\n\t%s'.green,
  331. root,
  332. serveURLs.join("\n\t")
  333. );
  334. }
  335. else
  336. console.log(
  337. 'Serving "%s" at %s (%s)'.green,
  338. root,
  339. openURL,
  340. serveURL
  341. );
  342. }
  343. // Launch browser
  344. if (openPath !== null)
  345. if (typeof openPath === "object") {
  346. openPath.forEach(function (p) {
  347. open(openURL + p, { app: browser });
  348. });
  349. } else {
  350. open(openURL + openPath, { app: browser });
  351. }
  352. });
  353. // Setup server to listen at port
  354. server.listen(port, host);
  355. // WebSocket
  356. var clients = [];
  357. server.addListener("upgrade", function (request, socket, head) {
  358. var ws = new WebSocket(request, socket, head);
  359. ws.onopen = function () {
  360. ws.send("connected");
  361. };
  362. if (wait > 0) {
  363. (function () {
  364. var wssend = ws.send;
  365. var waitTimeout;
  366. ws.send = function () {
  367. var args = arguments;
  368. if (waitTimeout) clearTimeout(waitTimeout);
  369. waitTimeout = setTimeout(function () {
  370. wssend.apply(ws, args);
  371. }, wait);
  372. };
  373. })();
  374. }
  375. ws.onclose = function () {
  376. clients = clients.filter(function (x) {
  377. return x !== ws;
  378. });
  379. };
  380. clients.push(ws);
  381. });
  382. var ignored = [
  383. function (testPath) {
  384. // Always ignore dotfiles (important e.g. because editor hidden temp files)
  385. return (
  386. testPath !== "." &&
  387. /(^[.#]|(?:__|~)$)/.test(path.basename(testPath))
  388. );
  389. },
  390. ];
  391. if (options.ignore) {
  392. ignored = ignored.concat(options.ignore);
  393. }
  394. if (options.ignorePattern) {
  395. ignored.push(options.ignorePattern);
  396. }
  397. // Setup file watcher
  398. LiveServer.watcher = chokidar.watch(watchPaths, {
  399. ignored: ignored,
  400. ignoreInitial: true,
  401. });
  402. function handleChange(changePath) {
  403. var cssChange = path.extname(changePath) === ".css" && !noCssInject;
  404. if (LiveServer.logLevel >= 1) {
  405. if (cssChange)
  406. console.log("CSS change detected".magenta, changePath);
  407. else console.log("Change detected".cyan, changePath);
  408. }
  409. clients.forEach(function (ws) {
  410. if (ws) ws.send(cssChange ? "refreshcss" : "reload");
  411. });
  412. }
  413. LiveServer.watcher
  414. .on("change", handleChange)
  415. .on("add", handleChange)
  416. .on("unlink", handleChange)
  417. .on("addDir", handleChange)
  418. .on("unlinkDir", handleChange)
  419. .on("ready", function () {
  420. if (LiveServer.logLevel >= 1) console.log("Ready for changes".cyan);
  421. })
  422. .on("error", function (err) {
  423. console.log("ERROR:".red, err);
  424. });
  425. return server;
  426. };
  427. LiveServer.shutdown = function () {
  428. var watcher = LiveServer.watcher;
  429. if (watcher) {
  430. watcher.close();
  431. }
  432. var server = LiveServer.server;
  433. if (server) server.close();
  434. };
  435. module.exports = LiveServer;