Websocket
[VSoRC/.git] / node_modules / node-static / lib / node-static.js
1 var fs     = require('fs')
2   , events = require('events')
3   , buffer = require('buffer')
4   , http   = require('http')
5   , url    = require('url')
6   , path   = require('path')
7   , mime   = require('mime')
8   , util   = require('./node-static/util');
9
10 // Current version
11 var version = [0, 7, 9];
12
13 var Server = function (root, options) {
14     if (root && (typeof(root) === 'object')) { options = root; root = null }
15
16     // resolve() doesn't normalize (to lowercase) drive letters on Windows
17     this.root    = path.normalize(path.resolve(root || '.'));
18     this.options = options || {};
19     this.cache   = 3600;
20
21     this.defaultHeaders  = {};
22     this.options.headers = this.options.headers || {};
23
24     this.options.indexFile = this.options.indexFile || "index.html";
25
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) {
30             this.cache = false;
31         }
32     }
33
34     if ('serverInfo' in this.options) {
35         this.serverInfo = this.options.serverInfo.toString();
36     } else {
37         this.serverInfo = 'node-static/' + version.join('.');
38     }
39
40     this.defaultHeaders['server'] = this.serverInfo;
41
42     if (this.cache !== false) {
43         this.defaultHeaders['cache-control'] = 'max-age=' + this.cache;
44     }
45
46     for (var k in this.defaultHeaders) {
47         this.options.headers[k] = this.options.headers[k] ||
48                                   this.defaultHeaders[k];
49     }
50 };
51
52 Server.prototype.serveDir = function (pathname, req, res, finish) {
53     var htmlIndex = path.join(pathname, this.options.indexFile),
54         that = this;
55
56     fs.stat(htmlIndex, function (e, stat) {
57         if (!e) {
58             var status = 200;
59             var headers = {};
60             var originalPathname = decodeURI(url.parse(req.url).pathname);
61             if (originalPathname.length && originalPathname.charAt(originalPathname.length - 1) !== '/') {
62                 return finish(301, { 'Location': originalPathname + '/' });
63             } else {
64                 that.respond(null, status, headers, [htmlIndex], stat, req, res, finish);
65             }
66         } else {
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);
72             });
73         }
74     });
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);
79         });
80     }
81 };
82
83 Server.prototype.serveFile = function (pathname, status, headers, req, res) {
84     var that = this;
85     var promise = new(events.EventEmitter);
86
87     pathname = this.resolve(pathname);
88
89     fs.stat(pathname, function (e, stat) {
90         if (e) {
91             return promise.emit('error', e);
92         }
93         that.respond(null, status, headers, [pathname], stat, req, res, function (status, headers) {
94             that.finish(status, headers, req, res, promise);
95         });
96     });
97     return promise;
98 };
99
100 Server.prototype.finish = function (status, headers, req, res, promise, callback) {
101     var result = {
102         status:  status,
103         headers: headers,
104         message: http.STATUS_CODES[status]
105     };
106
107     headers['server'] = this.serverInfo;
108
109     if (!status || status >= 400) {
110         if (callback) {
111             callback(result);
112         } else {
113             if (promise.listeners('error').length > 0) {
114                 promise.emit('error', result);
115             }
116             else {
117               res.writeHead(status, headers);
118               res.end();
119             }
120         }
121     } else {
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);
126             res.end();
127         }
128         callback && callback(null, result);
129         promise.emit('success', result);
130     }
131 };
132
133 Server.prototype.servePath = function (pathname, status, headers, req, res, finish) {
134     var that = this,
135         promise = new(events.EventEmitter);
136
137     pathname = this.resolve(pathname);
138
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) {
143             if (e) {
144                 finish(404, {});
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);
149             } else {
150                 finish(400, {});
151             }
152         });
153     } else {
154         // Forbidden
155         finish(403, {});
156     }
157     return promise;
158 };
159
160 Server.prototype.resolve = function (pathname) {
161     return path.resolve(path.join(this.root, pathname));
162 };
163
164 Server.prototype.serve = function (req, res, callback) {
165     var that    = this,
166         promise = new(events.EventEmitter),
167         pathname;
168
169     var finish = function (status, headers) {
170         that.finish(status, headers, req, res, promise, callback);
171     };
172
173     try {
174         pathname = decodeURI(url.parse(req.url).pathname);
175     }
176     catch(e) {
177         return process.nextTick(function() {
178             return finish(400, {});
179         });
180     }
181
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');
187         });
188     });
189     if (! callback) { return promise }
190 };
191
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.
194  */
195 Server.prototype.gzipOk = function (req, contentType) {
196     var enable = this.options.gzip;
197     if(enable &&
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;
202     }
203     return false;
204 }
205
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.
208  */
209 Server.prototype.respondGzip = function (pathname, status, contentType, _headers, files, stat, req, res, finish) {
210     var that = this;
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;
219                 files = [gzFile];
220             }
221             that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
222         });
223     } else {
224         // Client doesn't want gzip or we're sending multiple files
225         that.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
226     }
227 }
228
229 Server.prototype.parseByteRange = function (req, stat) {
230     var byteRange = {
231       from: 0,
232       to: 0,
233       valid: false
234     }
235
236     var rangeHeader = req.headers['range'];
237     var flavor = 'bytes=';
238
239     if (rangeHeader) {
240         if (rangeHeader.indexOf(flavor) == 0 && rangeHeader.indexOf(',') == -1) {
241             /* Parse */
242             rangeHeader = rangeHeader.substr(flavor.length).split('-');
243             byteRange.from = parseInt(rangeHeader[0]);
244             byteRange.to = parseInt(rangeHeader[1]);
245
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;
252             }
253
254             /* General byte range validation */
255             if (!isNaN(byteRange.from) && !!byteRange.to && 0 <= byteRange.from && byteRange.from < byteRange.to) {
256                 byteRange.valid = true;
257             } else {
258                 console.warn("Request contains invalid range header: ", rangeHeader);
259             }
260         } else {
261             console.warn("Request contains unsupported range header: ", rangeHeader);
262         }
263     }
264     return byteRange;
265 }
266
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],
270         headers         = {},
271         clientETag      = req.headers['if-none-match'],
272         clientMTime     = Date.parse(req.headers['if-modified-since']),
273         startByte       = 0,
274         length          = stat.size,
275         byteRange       = this.parseByteRange(req, stat);
276
277     /* Handle byte ranges */
278     if (files.length == 1 && byteRange.valid) {
279         if (byteRange.to < length) {
280
281             // Note: HTTP Range param is inclusive
282             startByte = byteRange.from;
283             length = byteRange.to - byteRange.from + 1;
284             status = 206;
285
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;
288
289         } else {
290             byteRange.valid = false;
291             console.warn("Range request exceeds file boundaries, goes until byte no", byteRange.to, "against file size of", length, "bytes");
292         }
293     }
294
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"));
298     }
299
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] }
304
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;
310
311     for (var k in _headers) { headers[k] = _headers[k] }
312
313     // Conditional GET
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
320         ['Content-Encoding',
321          'Content-Language',
322          'Content-Length',
323          'Content-Location',
324          'Content-MD5',
325          'Content-Range',
326          'Content-Type',
327          'Expires',
328          'Last-Modified'].forEach(function (entityHeader) {
329             delete headers[entityHeader];
330         });
331         finish(304, headers);
332     } else {
333         res.writeHead(status, headers);
334
335         this.stream(key, files, length, startByte, res, function (e) {
336             if (e) { return finish(500, {}) }
337             finish(status, headers);
338         });
339     }
340 };
341
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';
346
347     if(this.options.gzip) {
348         this.respondGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
349     } else {
350         this.respondNoGzip(pathname, status, contentType, _headers, files, stat, req, res, finish);
351     }
352 }
353
354 Server.prototype.stream = function (pathname, files, length, startByte, res, callback) {
355
356     (function streamFile(files, offset) {
357         var file = files.shift();
358
359         if (file) {
360             file = path.resolve(file) === path.normalize(file)  ? file : path.join(pathname || '.', file);
361
362             // Stream the file to the client
363             fs.createReadStream(file, {
364                 flags: 'r',
365                 mode: 0666,
366                 start: startByte,
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;
373                 }
374             }).on('close', function () {
375                 streamFile(files, offset);
376             }).on('error', function (err) {
377                 callback(err);
378                 console.error(err);
379             }).pipe(res, { end: false });
380         } else {
381             res.end();
382             callback(null, offset);
383         }
384     })(files.slice(0), 0);
385 };
386
387 // Exports
388 exports.Server       = Server;
389 exports.version      = version;
390 exports.mime         = mime;
391
392
393