UNPKG

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