initial commit
[JIRC.git] / 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
8 this.version = [0, 6, 0];
9
10 var mime = require('./node-static/mime');
11 var util = require('./node-static/util');
12
13 // In-memory file store
14 this.store = {};
15 this.indexStore = {};
16
17 this.Server = function (root, options) {
18 if (root && (typeof(root) === 'object')) { options = root, root = null }
19
20 this.root = path.resolve(root || '.');
21 this.options = options || {};
22 this.cache = 3600;
23
24 this.defaultHeaders = {};
25 this.options.headers = this.options.headers || {};
26
27 if ('cache' in this.options) {
28 if (typeof(this.options.cache) === 'number') {
29 this.cache = this.options.cache;
30 } else if (! this.options.cache) {
31 this.cache = false;
32 }
33 }
34
35 if ('serverInfo' in this.options) {
36 this.serverInfo = this.options.serverInfo.toString();
37 } else {
38 this.serverInfo = 'node-static/' + exports.version.join('.');
39 }
40
41 this.defaultHeaders['Server'] = this.serverInfo;
42
43 if (this.cache !== false) {
44 this.defaultHeaders['Cache-Control'] = 'max-age=' + this.cache;
45 }
46
47 for (var k in this.defaultHeaders) {
48 this.options.headers[k] = this.options.headers[k] ||
49 this.defaultHeaders[k];
50 }
51 };
52
53 this.Server.prototype.serveDir = function (pathname, req, res, finish) {
54 var htmlIndex = path.join(pathname, 'index.html'),
55 that = this;
56
57 fs.stat(htmlIndex, function (e, stat) {
58 if (!e) {
59 that.respond(null, 200, {}, [htmlIndex], stat, req, res, finish);
60 } else {
61 if (pathname in exports.store) {
62 streamFiles(exports.indexStore[pathname].files);
63 } else {
64 // Stream a directory of files as a single file.
65 fs.readFile(path.join(pathname, 'index.json'), function (e, contents) {
66 if (e) { return finish(404, {}) }
67 var index = JSON.parse(contents);
68 exports.indexStore[pathname] = index;
69 streamFiles(index.files);
70 });
71 }
72 }
73 });
74 function streamFiles(files) {
75 util.mstat(pathname, files, function (e, stat) {
76 that.respond(pathname, 200, {}, files, stat, req, res, finish);
77 });
78 }
79 };
80 this.Server.prototype.serveFile = function (pathname, status, headers, req, res) {
81 var that = this;
82 var promise = new(events.EventEmitter);
83
84 pathname = this.resolve(pathname);
85
86 fs.stat(pathname, function (e, stat) {
87 if (e) {
88 return promise.emit('error', e);
89 }
90 that.respond(null, status, headers, [pathname], stat, req, res, function (status, headers) {
91 that.finish(status, headers, req, res, promise);
92 });
93 });
94 return promise;
95 };
96 this.Server.prototype.finish = function (status, headers, req, res, promise, callback) {
97 var result = {
98 status: status,
99 headers: headers,
100 message: http.STATUS_CODES[status]
101 };
102
103 headers['Server'] = this.serverInfo;
104
105 if (!status || status >= 400) {
106 if (callback) {
107 callback(result);
108 } else {
109 if (promise.listeners('error').length > 0) {
110 promise.emit('error', result);
111 }
112 res.writeHead(status, headers);
113 res.end();
114 }
115 } else {
116 // Don't end the request here, if we're streaming;
117 // it's taken care of in `prototype.stream`.
118 if (status !== 200 || req.method !== 'GET') {
119 res.writeHead(status, headers);
120 res.end();
121 }
122 callback && callback(null, result);
123 promise.emit('success', result);
124 }
125 };
126
127 this.Server.prototype.servePath = function (pathname, status, headers, req, res, finish) {
128 var that = this,
129 promise = new(events.EventEmitter);
130
131 pathname = this.resolve(pathname);
132
133 // Only allow GET and HEAD requests
134 if (req.method !== 'GET' && req.method !== 'HEAD') {
135 finish(405, { 'Allow': 'GET, HEAD' });
136 return promise;
137 }
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 this.Server.prototype.resolve = function (pathname) {
160 return path.resolve(path.join(this.root, pathname));
161 };
162 this.Server.prototype.serve = function (req, res, callback) {
163 var that = this,
164 promise = new(events.EventEmitter);
165
166 var pathname = decodeURI(url.parse(req.url).pathname);
167
168 var finish = function (status, headers) {
169 that.finish(status, headers, req, res, promise, callback);
170 };
171
172 process.nextTick(function () {
173 that.servePath(pathname, 200, {}, req, res, finish).on('success', function (result) {
174 promise.emit('success', result);
175 }).on('error', function (err) {
176 promise.emit('error');
177 });
178 });
179 if (! callback) { return promise }
180 };
181
182 this.Server.prototype.respond = function (pathname, status, _headers, files, stat, req, res, finish) {
183 var mtime = Date.parse(stat.mtime),
184 key = pathname || files[0],
185 headers = {};
186
187 // Copy default headers
188 for (var k in this.options.headers) { headers[k] = this.options.headers[k] }
189
190 headers['ETag'] = JSON.stringify([stat.ino, stat.size, mtime].join('-'));
191 headers['Date'] = new(Date)().toUTCString();
192 headers['Last-Modified'] = new(Date)(stat.mtime).toUTCString();
193
194 // Conditional GET
195 // If the "If-Modified-Since" or "If-None-Match" headers
196 // match the conditions, send a 304 Not Modified.
197 if (req.headers['if-none-match'] === headers['ETag'] ||
198 Date.parse(req.headers['if-modified-since']) >= mtime) {
199 finish(304, headers);
200 } else {
201 var fileExtension = path.extname(files[0]).slice(1).toLowerCase();
202 headers['Content-Length'] = stat.size;
203 headers['Content-Type'] = mime.contentTypes[fileExtension] ||
204 'application/octet-stream';
205
206 for (var k in _headers) { headers[k] = _headers[k] }
207
208 res.writeHead(status, headers);
209
210 if (req.method === 'HEAD') {
211 finish(200, headers);
212 return;
213 }
214
215 // If the file was cached and it's not older
216 // than what's on disk, serve the cached version.
217 if (this.cache && (key in exports.store) &&
218 exports.store[key].stat.mtime >= stat.mtime) {
219 res.end(exports.store[key].buffer);
220 finish(status, headers);
221 } else {
222 this.stream(pathname, files, new(buffer.Buffer)(stat.size), res, function (e, buffer) {
223 if (e) { return finish(500, {}) }
224 exports.store[key] = {
225 stat: stat,
226 buffer: buffer,
227 timestamp: Date.now()
228 };
229 finish(status, headers);
230 });
231 }
232 }
233 };
234
235 this.Server.prototype.stream = function (pathname, files, buffer, res, callback) {
236 (function streamFile(files, offset) {
237 var file = files.shift();
238
239 if (file) {
240 file = file[0] === '/' ? file : path.join(pathname || '.', file);
241
242 // Stream the file to the client
243 fs.createReadStream(file, {
244 flags: 'r',
245 mode: 0666
246 }).on('data', function (chunk) {
247 chunk.copy(buffer, offset);
248 offset += chunk.length;
249 }).on('close', function () {
250 streamFile(files, offset);
251 }).on('error', function (err) {
252 callback(err);
253 console.error(err);
254 }).pipe(res, { end: false });
255 } else {
256 res.end();
257 callback(null, buffer, offset);
258 }
259 })(files.slice(0), 0);
260 };