2 , events = require('events')
3 , buffer = require('buffer')
4 , http = require('http')
6 , path = require('path')
7 , mime = require('mime')
8 , util = require('./node-static/util');
11 var version = [0, 7, 9];
13 var Server = function (root, options) {
14 if (root && (typeof(root) === 'object')) { options = root; root = null }
16 // resolve() doesn't normalize (to lowercase) drive letters on Windows
17 this.root = path.normalize(path.resolve(root || '.'));
18 this.options = options || {};
21 this.defaultHeaders = {};
22 this.options.headers = this.options.headers || {};
24 this.options.indexFile = this.options.indexFile || "index.html";
26 if ('cache' in this.options) {
27 if (typeof(this.options.cache) === 'number') {
28 this.cache = this.options.cache;
29 } else if (! this.options.cache) {
34 if ('serverInfo' in this.options) {
35 this.serverInfo = this.options.serverInfo.toString();
37 this.serverInfo = 'node-static/' + version.join('.');
40 this.defaultHeaders['server'] = this.serverInfo;
42 if (this.cache !== false) {
43 this.defaultHeaders['cache-control'] = 'max-age=' + this.cache;
46 for (var k in this.defaultHeaders) {
47 this.options.headers[k] = this.options.headers[k] ||
48 this.defaultHeaders[k];
52 Server.prototype.serveDir = function (pathname, req, res, finish) {
53 var htmlIndex = path.join(pathname, this.options.indexFile),
56 fs.stat(htmlIndex, function (e, stat) {
60 var originalPathname = decodeURI(url.parse(req.url).pathname);
61 if (originalPathname.length && originalPathname.charAt(originalPathname.length - 1) !== '/') {
62 return finish(301, { 'Location': originalPathname + '/' });
64 that.respond(null, status, headers, [htmlIndex], stat, req, res, finish);
67 // Stream a directory of files as a single file.
68 fs.readFile(path.join(pathname, 'index.json'), function (e, contents) {
69 if (e) { return finish(404, {}) }
70 var index = JSON.parse(contents);
71 streamFiles(index.files);
75 function streamFiles(files) {
76 util.mstat(pathname, files, function (e, stat) {
77 if (e) { return finish(404, {}) }
78 that.respond(pathname, 200, {}, files, stat, req, res, finish);
83 Server.prototype.serveFile = function (pathname, status, headers, req, res) {
85 var promise = new(events.EventEmitter);
87 pathname = this.resolve(pathname);
89 fs.stat(pathname, function (e, stat) {
91 return promise.emit('error', e);
93 that.respond(null, status, headers, [pathname], stat, req, res, function (status, headers) {
94 that.finish(status, headers, req, res, promise);
100 Server.prototype.finish = function (status, headers, req, res, promise, callback) {
104 message: http.STATUS_CODES[status]
107 headers['server'] = this.serverInfo;
109 if (!status || status >= 400) {
113 if (promise.listeners('error').length > 0) {
114 promise.emit('error', result);
117 res.writeHead(status, headers);
122 // Don't end the request here, if we're streaming;
123 // it's taken care of in `prototype.stream`.
124 if (status !== 200 || req.method !== 'GET') {
125 res.writeHead(status, headers);
128 callback && callback(null, result);
129 promise.emit('success', result);
133 Server.prototype.servePath = function (pathname, status, headers, req, res, finish) {
135 promise = new(events.EventEmitter);
137 pathname = this.resolve(pathname);
139 // Make sure we're not trying to access a
140 // file outside of the root.
141 if (pathname.indexOf(that.root) === 0) {
142 fs.stat(pathname, function (e, stat) {
145 } else if (stat.isFile()) { // Stream a single file.
146 that.respond(null, status, headers, [pathname], stat, req, res, finish);
147 } else if (stat.isDirectory()) { // Stream a directory of files.
148 that.serveDir(pathname, req, res, finish);
160 Server.prototype.resolve = function (pathname) {
161 return path.resolve(path.join(this.root, pathname));
164 Server.prototype.serve = function (req, res, callback) {
166 promise = new(events.EventEmitter),
169 var finish = function (status, headers) {
170 that.finish(status, headers, req, res, promise, callback);
174 pathname = decodeURI(url.parse(req.url).pathname);
177 return process.nextTick(function() {
178 return finish(400, {});
182 process.nextTick(function () {
183 that.servePath(pathname, 200, {}, req, res, finish).on('success', function (result) {
184 promise.emit('success', result);
185 }).on('error', function (err) {
186 promise.emit('error');
189 if (! callback) { return promise }
192 /* Check if we should consider sending a gzip version of the file based on the
193 * file content type and client's Accept-Encoding header value.
195 Server.prototype.gzipOk = function (req, contentType) {
196 var enable = this.options.gzip;
198 (typeof enable === 'boolean' ||
199 (contentType && (enable instanceof RegExp) && enable.test(contentType)))) {
200 var acceptEncoding = req.headers['accept-encoding'];
201 return acceptEncoding && acceptEncoding.indexOf("gzip") >= 0;
206 /* Send a gzipped version of the file if the options and the client indicate gzip is enabled and
207 * we find a .gz file mathing the static resource requested.
209 Server.prototype.respondGzip = function (pathname, status, contentType, _headers, files, stat, req, res, finish) {
211 if (files.length == 1 && this.gzipOk(req, contentType)) {
212 var gzFile = files[0] + ".gz";
213 fs.stat(gzFile, function (e, gzStat) {
214 if (!e && gzStat.isFile()) {
215 var vary = _headers['Vary'];
216 _headers['Vary'] = (vary && vary != 'Accept-Encoding' ? vary + ', ' : '') + 'Accept-Encoding';
217 _headers['Content-Encoding'] = 'gzip';
218 stat.size = gzStat.size;
221 that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
224 // Client doesn't want gzip or we're sending multiple files
225 that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
229 Server.prototype.parseByteRange = function (req, stat) {
236 var rangeHeader = req.headers['range'];
237 var flavor = 'bytes=';
240 if (rangeHeader.indexOf(flavor) == 0 && rangeHeader.indexOf(',') == -1) {
242 rangeHeader = rangeHeader.substr(flavor.length).split('-');
243 byteRange.from = parseInt(rangeHeader[0]);
244 byteRange.to = parseInt(rangeHeader[1]);
246 /* Replace empty fields of differential requests by absolute values */
247 if (isNaN(byteRange.from) && !isNaN(byteRange.to)) {
248 byteRange.from = stat.size - byteRange.to;
249 byteRange.to = stat.size ? stat.size - 1 : 0;
250 } else if (!isNaN(byteRange.from) && isNaN(byteRange.to)) {
251 byteRange.to = stat.size ? stat.size - 1 : 0;
254 /* General byte range validation */
255 if (!isNaN(byteRange.from) && !!byteRange.to && 0 <= byteRange.from && byteRange.from < byteRange.to) {
256 byteRange.valid = true;
258 console.warn("Request contains invalid range header: ", rangeHeader);
261 console.warn("Request contains unsupported range header: ", rangeHeader);
267 Server.prototype.respondNoGzip = function (pathname, status, contentType, _headers, files, stat, req, res, finish) {
268 var mtime = Date.parse(stat.mtime),
269 key = pathname || files[0],
271 clientETag = req.headers['if-none-match'],
272 clientMTime = Date.parse(req.headers['if-modified-since']),
275 byteRange = this.parseByteRange(req, stat);
277 /* Handle byte ranges */
278 if (files.length == 1 && byteRange.valid) {
279 if (byteRange.to < length) {
281 // Note: HTTP Range param is inclusive
282 startByte = byteRange.from;
283 length = byteRange.to - byteRange.from + 1;
286 // Set Content-Range response header (we advertise initial resource size on server here (stat.size))
287 headers['Content-Range'] = 'bytes ' + byteRange.from + '-' + byteRange.to + '/' + stat.size;
290 byteRange.valid = false;
291 console.warn("Range request exceeds file boundaries, goes until byte no", byteRange.to, "against file size of", length, "bytes");
295 /* In any case, check for unhandled byte range headers */
296 if (!byteRange.valid && req.headers['range']) {
297 console.error(new Error("Range request present but invalid, might serve whole file instead"));
300 // Copy default headers
301 for (var k in this.options.headers) { headers[k] = this.options.headers[k] }
302 // Copy custom headers
303 for (var k in _headers) { headers[k] = _headers[k] }
305 headers['Etag'] = JSON.stringify([stat.ino, stat.size, mtime].join('-'));
306 headers['Date'] = new(Date)().toUTCString();
307 headers['Last-Modified'] = new(Date)(stat.mtime).toUTCString();
308 headers['Content-Type'] = contentType;
309 headers['Content-Length'] = length;
311 for (var k in _headers) { headers[k] = _headers[k] }
314 // If the "If-Modified-Since" or "If-None-Match" headers
315 // match the conditions, send a 304 Not Modified.
316 if ((clientMTime || clientETag) &&
317 (!clientETag || clientETag === headers['Etag']) &&
318 (!clientMTime || clientMTime >= mtime)) {
319 // 304 response should not contain entity headers
328 'Last-Modified'].forEach(function (entityHeader) {
329 delete headers[entityHeader];
331 finish(304, headers);
333 res.writeHead(status, headers);
335 this.stream(key, files, length, startByte, res, function (e) {
336 if (e) { return finish(500, {}) }
337 finish(status, headers);
342 Server.prototype.respond = function (pathname, status, _headers, files, stat, req, res, finish) {
343 var contentType = _headers['Content-Type'] ||
344 mime.lookup(files[0]) ||
345 'application/octet-stream';
347 if(this.options.gzip) {
348 this.respondGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
350 this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
354 Server.prototype.stream = function (pathname, files, length, startByte, res, callback) {
356 (function streamFile(files, offset) {
357 var file = files.shift();
360 file = path.resolve(file) === path.normalize(file) ? file : path.join(pathname || '.', file);
362 // Stream the file to the client
363 fs.createReadStream(file, {
367 end: startByte + (length ? length - 1 : 0)
368 }).on('data', function (chunk) {
369 // Bounds check the incoming chunk and offset, as copying
370 // a buffer from an invalid offset will throw an error and crash
371 if (chunk.length && offset < length && offset >= 0) {
372 offset += chunk.length;
374 }).on('close', function () {
375 streamFile(files, offset);
376 }).on('error', function (err) {
379 }).pipe(res, { end: false });
382 callback(null, offset);
384 })(files.slice(0), 0);
388 exports.Server = Server;
389 exports.version = version;