Path: blob/master/node_modules/node-static/lib/node-static.js
334 views
var fs = require('fs')1, events = require('events')2, buffer = require('buffer')3, http = require('http')4, url = require('url')5, path = require('path')6, mime = require('mime')7, util = require('./node-static/util');89// Current version10var version = [0, 7, 9];1112var Server = function (root, options) {13if (root && (typeof(root) === 'object')) { options = root; root = null }1415// resolve() doesn't normalize (to lowercase) drive letters on Windows16this.root = path.normalize(path.resolve(root || '.'));17this.options = options || {};18this.cache = 3600;1920this.defaultHeaders = {};21this.options.headers = this.options.headers || {};2223this.options.indexFile = this.options.indexFile || "index.html";2425if ('cache' in this.options) {26if (typeof(this.options.cache) === 'number') {27this.cache = this.options.cache;28} else if (! this.options.cache) {29this.cache = false;30}31}3233if ('serverInfo' in this.options) {34this.serverInfo = this.options.serverInfo.toString();35} else {36this.serverInfo = 'node-static/' + version.join('.');37}3839this.defaultHeaders['server'] = this.serverInfo;4041if (this.cache !== false) {42this.defaultHeaders['cache-control'] = 'max-age=' + this.cache;43}4445for (var k in this.defaultHeaders) {46this.options.headers[k] = this.options.headers[k] ||47this.defaultHeaders[k];48}49};5051Server.prototype.serveDir = function (pathname, req, res, finish) {52var htmlIndex = path.join(pathname, this.options.indexFile),53that = this;5455fs.stat(htmlIndex, function (e, stat) {56if (!e) {57var status = 200;58var headers = {};59var originalPathname = decodeURI(url.parse(req.url).pathname);60if (originalPathname.length && originalPathname.charAt(originalPathname.length - 1) !== '/') {61return finish(301, { 'Location': originalPathname + '/' });62} else {63that.respond(null, status, headers, [htmlIndex], stat, req, res, finish);64}65} else {66// Stream a directory of files as a single file.67fs.readFile(path.join(pathname, 'index.json'), function (e, contents) {68if (e) { return finish(404, {}) }69var index = JSON.parse(contents);70streamFiles(index.files);71});72}73});74function streamFiles(files) {75util.mstat(pathname, files, function (e, stat) {76if (e) { return finish(404, {}) }77that.respond(pathname, 200, {}, files, stat, req, res, finish);78});79}80};8182Server.prototype.serveFile = function (pathname, status, headers, req, res) {83var that = this;84var promise = new(events.EventEmitter);8586pathname = this.resolve(pathname);8788fs.stat(pathname, function (e, stat) {89if (e) {90return promise.emit('error', e);91}92that.respond(null, status, headers, [pathname], stat, req, res, function (status, headers) {93that.finish(status, headers, req, res, promise);94});95});96return promise;97};9899Server.prototype.finish = function (status, headers, req, res, promise, callback) {100var result = {101status: status,102headers: headers,103message: http.STATUS_CODES[status]104};105106headers['server'] = this.serverInfo;107108if (!status || status >= 400) {109if (callback) {110callback(result);111} else {112if (promise.listeners('error').length > 0) {113promise.emit('error', result);114}115else {116res.writeHead(status, headers);117res.end();118}119}120} else {121// Don't end the request here, if we're streaming;122// it's taken care of in `prototype.stream`.123if (status !== 200 || req.method !== 'GET') {124res.writeHead(status, headers);125res.end();126}127callback && callback(null, result);128promise.emit('success', result);129}130};131132Server.prototype.servePath = function (pathname, status, headers, req, res, finish) {133var that = this,134promise = new(events.EventEmitter);135136pathname = this.resolve(pathname);137138// Make sure we're not trying to access a139// file outside of the root.140if (pathname.indexOf(that.root) === 0) {141fs.stat(pathname, function (e, stat) {142if (e) {143finish(404, {});144} else if (stat.isFile()) { // Stream a single file.145that.respond(null, status, headers, [pathname], stat, req, res, finish);146} else if (stat.isDirectory()) { // Stream a directory of files.147that.serveDir(pathname, req, res, finish);148} else {149finish(400, {});150}151});152} else {153// Forbidden154finish(403, {});155}156return promise;157};158159Server.prototype.resolve = function (pathname) {160return path.resolve(path.join(this.root, pathname));161};162163Server.prototype.serve = function (req, res, callback) {164var that = this,165promise = new(events.EventEmitter),166pathname;167168var finish = function (status, headers) {169that.finish(status, headers, req, res, promise, callback);170};171172try {173pathname = decodeURI(url.parse(req.url).pathname);174}175catch(e) {176return process.nextTick(function() {177return finish(400, {});178});179}180181process.nextTick(function () {182that.servePath(pathname, 200, {}, req, res, finish).on('success', function (result) {183promise.emit('success', result);184}).on('error', function (err) {185promise.emit('error');186});187});188if (! callback) { return promise }189};190191/* Check if we should consider sending a gzip version of the file based on the192* file content type and client's Accept-Encoding header value.193*/194Server.prototype.gzipOk = function (req, contentType) {195var enable = this.options.gzip;196if(enable &&197(typeof enable === 'boolean' ||198(contentType && (enable instanceof RegExp) && enable.test(contentType)))) {199var acceptEncoding = req.headers['accept-encoding'];200return acceptEncoding && acceptEncoding.indexOf("gzip") >= 0;201}202return false;203}204205/* Send a gzipped version of the file if the options and the client indicate gzip is enabled and206* we find a .gz file mathing the static resource requested.207*/208Server.prototype.respondGzip = function (pathname, status, contentType, _headers, files, stat, req, res, finish) {209var that = this;210if (files.length == 1 && this.gzipOk(req, contentType)) {211var gzFile = files[0] + ".gz";212fs.stat(gzFile, function (e, gzStat) {213if (!e && gzStat.isFile()) {214var vary = _headers['Vary'];215_headers['Vary'] = (vary && vary != 'Accept-Encoding' ? vary + ', ' : '') + 'Accept-Encoding';216_headers['Content-Encoding'] = 'gzip';217stat.size = gzStat.size;218files = [gzFile];219}220that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);221});222} else {223// Client doesn't want gzip or we're sending multiple files224that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);225}226}227228Server.prototype.parseByteRange = function (req, stat) {229var byteRange = {230from: 0,231to: 0,232valid: false233}234235var rangeHeader = req.headers['range'];236var flavor = 'bytes=';237238if (rangeHeader) {239if (rangeHeader.indexOf(flavor) == 0 && rangeHeader.indexOf(',') == -1) {240/* Parse */241rangeHeader = rangeHeader.substr(flavor.length).split('-');242byteRange.from = parseInt(rangeHeader[0]);243byteRange.to = parseInt(rangeHeader[1]);244245/* Replace empty fields of differential requests by absolute values */246if (isNaN(byteRange.from) && !isNaN(byteRange.to)) {247byteRange.from = stat.size - byteRange.to;248byteRange.to = stat.size ? stat.size - 1 : 0;249} else if (!isNaN(byteRange.from) && isNaN(byteRange.to)) {250byteRange.to = stat.size ? stat.size - 1 : 0;251}252253/* General byte range validation */254if (!isNaN(byteRange.from) && !!byteRange.to && 0 <= byteRange.from && byteRange.from < byteRange.to) {255byteRange.valid = true;256} else {257console.warn("Request contains invalid range header: ", rangeHeader);258}259} else {260console.warn("Request contains unsupported range header: ", rangeHeader);261}262}263return byteRange;264}265266Server.prototype.respondNoGzip = function (pathname, status, contentType, _headers, files, stat, req, res, finish) {267var mtime = Date.parse(stat.mtime),268key = pathname || files[0],269headers = {},270clientETag = req.headers['if-none-match'],271clientMTime = Date.parse(req.headers['if-modified-since']),272startByte = 0,273length = stat.size,274byteRange = this.parseByteRange(req, stat);275276/* Handle byte ranges */277if (files.length == 1 && byteRange.valid) {278if (byteRange.to < length) {279280// Note: HTTP Range param is inclusive281startByte = byteRange.from;282length = byteRange.to - byteRange.from + 1;283status = 206;284285// Set Content-Range response header (we advertise initial resource size on server here (stat.size))286headers['Content-Range'] = 'bytes ' + byteRange.from + '-' + byteRange.to + '/' + stat.size;287288} else {289byteRange.valid = false;290console.warn("Range request exceeds file boundaries, goes until byte no", byteRange.to, "against file size of", length, "bytes");291}292}293294/* In any case, check for unhandled byte range headers */295if (!byteRange.valid && req.headers['range']) {296console.error(new Error("Range request present but invalid, might serve whole file instead"));297}298299// Copy default headers300for (var k in this.options.headers) { headers[k] = this.options.headers[k] }301// Copy custom headers302for (var k in _headers) { headers[k] = _headers[k] }303304headers['Etag'] = JSON.stringify([stat.ino, stat.size, mtime].join('-'));305headers['Date'] = new(Date)().toUTCString();306headers['Last-Modified'] = new(Date)(stat.mtime).toUTCString();307headers['Content-Type'] = contentType;308headers['Content-Length'] = length;309310for (var k in _headers) { headers[k] = _headers[k] }311312// Conditional GET313// If the "If-Modified-Since" or "If-None-Match" headers314// match the conditions, send a 304 Not Modified.315if ((clientMTime || clientETag) &&316(!clientETag || clientETag === headers['Etag']) &&317(!clientMTime || clientMTime >= mtime)) {318// 304 response should not contain entity headers319['Content-Encoding',320'Content-Language',321'Content-Length',322'Content-Location',323'Content-MD5',324'Content-Range',325'Content-Type',326'Expires',327'Last-Modified'].forEach(function (entityHeader) {328delete headers[entityHeader];329});330finish(304, headers);331} else {332res.writeHead(status, headers);333334this.stream(key, files, length, startByte, res, function (e) {335if (e) { return finish(500, {}) }336finish(status, headers);337});338}339};340341Server.prototype.respond = function (pathname, status, _headers, files, stat, req, res, finish) {342var contentType = _headers['Content-Type'] ||343mime.lookup(files[0]) ||344'application/octet-stream';345346if(this.options.gzip) {347this.respondGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);348} else {349this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);350}351}352353Server.prototype.stream = function (pathname, files, length, startByte, res, callback) {354355(function streamFile(files, offset) {356var file = files.shift();357358if (file) {359file = path.resolve(file) === path.normalize(file) ? file : path.join(pathname || '.', file);360361// Stream the file to the client362fs.createReadStream(file, {363flags: 'r',364mode: 0666,365start: startByte,366end: startByte + (length ? length - 1 : 0)367}).on('data', function (chunk) {368// Bounds check the incoming chunk and offset, as copying369// a buffer from an invalid offset will throw an error and crash370if (chunk.length && offset < length && offset >= 0) {371offset += chunk.length;372}373}).on('close', function () {374streamFile(files, offset);375}).on('error', function (err) {376callback(err);377console.error(err);378}).pipe(res, { end: false });379} else {380res.end();381callback(null, offset);382}383})(files.slice(0), 0);384};385386// Exports387exports.Server = Server;388exports.version = version;389exports.mime = mime;390391392393394395