UNPKG

6.91 kBJavaScriptView Raw
1'use strict';
2
3/**
4 * Static plugin.
5 * Provides basic file-serving capabilities. By default tries to serve all paths
6 * with extensions. To disable this behaviour set `route` parameter to `false`
7 *
8 * // Default options
9 * Crixalis.plugin('static');
10 *
11 * // Custom options
12 * Crixalis.plugin('static', {
13 * // Do not create route
14 * route : false,
15 *
16 * // Do not follow symlinks
17 * symlinks : false,
18 *
19 * // Cache files and stat calls for 10 seconds
20 * cacheTime : 10000
21 * });
22 *
23 * @module Crixalis
24 * @submodule static
25 * @for Controller
26 */
27
28var Crixalis = require('../controller'),
29 fs = require('fs'),
30 zlib = require('zlib'),
31 crypto = require('crypto'),
32 mime = require('mime'),
33 normalize = require('path').normalize,
34 AsyncCache = require('async-cache'),
35 extensions = {
36 gzip : '.gz',
37 deflate : '.def'
38 };
39
40function send (context, file, size) {
41 context.emit('response');
42
43 context.sendHeaders();
44
45 if (!context.is_head && file) {
46 if (size > context._streamLimit) {
47 /* Stream big files */
48 fs.createReadStream(file)
49 .on('end', function () {
50 context._destroy();
51 })
52 .pipe(context.res);
53 } else {
54 /* Serve small files from cache */
55 context._fileCache.get(file, function (error, result) {
56 context.res.end(result);
57 context._destroy();
58 });
59 }
60 } else {
61 context.res.end();
62 context._destroy();
63 }
64}
65
66module.exports = function (options) {
67 options = Object(options);
68
69 /* TODO: configurable cache size */
70
71 var stat = options.symlinks === false? fs.stat : fs.lstat,
72 cacheTime = Number(options.cacheTime || 307),
73 statCache = new AsyncCache({
74 max : 1 << 7,
75 maxAge : cacheTime,
76 length : function () { return 1 },
77 load : function (file, callback) {
78 stat(file, function (error, result) {
79 fileCache.del(file);
80 callback(error, result);
81 })
82 }
83 }),
84 fileCache = new AsyncCache({
85 max : 1 << 20,
86 length : function () { return arguments[0].length },
87 load : fs.readFile
88 });
89
90 /* Default static route */
91 if (options.route !== false) {
92 Crixalis.router().set({
93 methods: ['GET', 'HEAD'],
94 pattern: /^\/(.+?)(\.[^.\/]+)$/,
95 capture: {
96 '$1': 'path',
97 '$2': 'extension'
98 }
99 }).to(function () {
100 var that = this,
101 extension = this.params.extension.slice(1),
102 path = normalize(this.staticPath + '/' + this.params.path + '.' + extension);
103
104 this.async = true;
105
106 if (path.indexOf(normalize(this.staticPath))) {
107 this.emit('default');
108 that.render();
109 return;
110 }
111
112 this[typeof this[extension] === 'function'? extension : 'serve'](path, function (error) {
113 that.emit('default');
114 that.render();
115 });
116 });
117 }
118
119 /**
120 * Where Crixalis should look for static files. Crixalis will not
121 * create any directories, you should do it yourself.
122 * @property staticPath
123 * @type String
124 * @default public
125 */
126 Crixalis.define('property', 'staticPath', 'public');
127
128 /**
129 * Where Crixalis should store compressed files. Crixalis will not
130 * create any directories, you should do it yourself.
131 * @property cachePath
132 * @type String
133 * @default .
134 */
135 Crixalis.define('property', 'cachePath', '.');
136
137 /**
138 * Expirity data by mime type in milliseconds
139 * @property expires
140 * @type Object
141 */
142 Crixalis.define('property', 'expires', {});
143
144 /**
145 * Files smaller than 32K will not be streamed
146 * @property _streamLimit
147 * @type Number
148 * @default 32768
149 * @private
150 */
151 Crixalis.define('property', '_streamLimit', 32768);
152
153 /**
154 * Cache for small files
155 * @property _fileCache
156 * @type AsyncCache
157 * @private
158 */
159 Crixalis.define('property', '_fileCache', fileCache, { writable: false });
160
161 /**
162 * Cache for data from fs.stat and fs.lstat calls
163 * @property _statCache
164 * @type AsyncCache
165 * @private
166 */
167 Crixalis.define('property', '_statCache', statCache, { writable: false });
168
169 /**
170 * Serve static file
171 *
172 * this.serve('../public/index.html');
173 *
174 * When callback is omitted this.error() is called
175 * in case of any errors
176 *
177 * @method serve
178 * @param {String} path Path to file
179 * @param {Function} [callback] Callback
180 * @async
181 * @chainable
182 */
183 Crixalis.define('method', 'serve', function (path, callback) {
184 var that = this,
185 stats;
186
187 callback = callback || this.error;
188
189 if (typeof callback !== 'function') {
190 throw new Error('Callback must be a function');
191 }
192
193 this._statCache.get(path, function (error, result) {
194 if (error) {
195 callback.call(that, error);
196 return;
197 }
198
199 if (!result.isFile()) {
200 callback.call(that, new Error('Not a file'));
201 return;
202 }
203
204 that.stash.path = path;
205 that.stash.stat = result;
206
207 that.render('file');
208 });
209
210 return this;
211 });
212
213 Crixalis.define('view', 'file', function () {
214 var that = this,
215 stash = this.stash,
216 headers = this.headers,
217 contentType = mime.lookup(stash.path),
218 expires = this.expires[contentType],
219 mtime = stash.stat.mtime,
220 file, compression;
221
222 /* Add charset to content-type */
223 contentType += '; charset=';
224 contentType += stash.charset || 'utf-8';
225
226 headers['Last-Modified'] = mtime.toUTCString();
227
228 /* TODO: Check ETag */
229
230 /* Check file modification time */
231 if (+mtime === Date.parse(this.req.headers['if-modified-since'])) {
232 this.code = 304;
233 send(this);
234 return false;
235 }
236
237 headers['Vary'] = 'Accept-Encoding';
238 headers['Content-Length'] = stash.stat.size;
239 headers['Content-Type'] = contentType;
240
241 if (expires) {
242 headers['Expires'] = (new Date(this.start + expires)).toUTCString();
243 }
244
245 /* Detect compression support */
246 if (typeof this.compression === 'function') {
247 compression = this.compression();
248 }
249
250 /* Compress if possible */
251 if (compression) {
252 headers['Content-Encoding'] = compression;
253
254 file = this.cachePath;
255 file += '/';
256
257 /* Create unique file name in cache directory */
258 file += crypto.createHash('md5')
259 .update(stash.path + mtime)
260 .digest('hex');
261 file += extensions[compression];
262
263 this._statCache.get(file, function (error, result) {
264 if (!error) {
265 send(that, file, headers['Content-Length'] = result.size);
266 return;
267 }
268
269 var compressor = zlib[compression === 'gzip'? 'createGzip' : 'createDeflate'](),
270 length = 0,
271
272 /* Source file */
273 stream = fs.createReadStream(stash.path),
274
275 /* Compressed file in cache */
276 cache = fs.createWriteStream(file);
277
278 compressor
279 .on('data', function (data) {
280 length += data.length;
281 });
282
283 cache
284 .on('close', function () {
285 send(that, file, headers['Content-Length'] = length);
286 });
287
288 stream
289 .pipe(compressor)
290 .pipe(cache);
291 });
292
293 return false;
294 }
295
296 /* Send file */
297 send(this, stash.path, headers['Content-Length']);
298
299 return false;
300 });
301};