UNPKG

9.13 kBJavaScriptView Raw
1/*
2 * pub-server serve-statics.js
3 *
4 * serve static files by scanning static paths at startup - default depth:3
5 * allows many static paths and single files (like favicons) mounted on root
6 * without stat'ing the file system on each path for each request
7 * the tradeoff is a delay or 404s serving statics during initial scan
8 *
9 * API: serveStatics(opts, cb) returns serveStatics object, calls cb after scan
10 * serveStatics.serveRoutes(server) - serve routes and start watches
11 * serveStatics.outputAll() - copy static inventory to outputs[0] (for pub -O)
12 *
13 * uses send (same as express.static)
14 *
15 * TODO: pause and retry while scanning
16 * merge into pub-src-fs/fsbase?
17 * add file cache and/or files-list cache?
18 * extend to serve files from remote storage
19 * (supports only local files for now)
20 *
21 * copyright 2015, Jurgen Leschner - github.com/jldec - MIT license
22 */
23
24var debug = require('debug')('pub:static');
25var u = require('pub-util');
26var fs = require('fs-extra');
27var path = require('path');
28var fsbase = require('pub-src-fs/fs-base');
29var send = require('send');
30var watch = require('./watch');
31
32module.exports = function serveStatics(opts, cb) {
33
34 if (!(this instanceof serveStatics)) return new serveStatics(opts, cb);
35 var self = this;
36 var log = opts.log;
37 var staticPaths = opts.staticPaths;
38 var staticPathsRev = opts.staticPaths.slice(0).reverse();
39
40 var defaultOutput = (opts.outputs && opts.outputs[0]);
41 var outputExtension = defaultOutput && defaultOutput.extension;
42 var noOutputExtensions = outputExtension === '';
43
44 self.file$ = {}; // maps each possible file request path -> staticPath
45 self.scanCnt = 0; // how many scans have been completed
46 self.defaultFile = ''; // default file to serve if no other pages available
47 self.server; // initialized by self.serveRoutes(server)
48
49 // global opts
50
51 // server retry with extensions when requested file not found - array
52 self.extensions = ('extensions' in opts) ? opts.extensions :
53 noOutputExtensions ? [] : ['.htm', '.html'];
54
55 // server retry with trailing slash (similar to extensions) - bool
56 self.trailingSlash = 'noTrailingSlash' in opts ? !opts.noTrailingSlash : true;
57
58 // additionally serve 'path/index' as just 'path' (1st match wins) - array
59 // [inverse of generator.output() for pages with _href = directory]
60 self.indexFiles =
61 'indexFiles' in opts ? opts.indexFiles :
62 noOutputExtensions ? ['index'] : ['index.html'];
63
64 // send Content-Type=text/html header for extensionless files
65 self.noHtmlExtensions = opts.noHtmlExtensions || noOutputExtensions;
66
67 if (self.indexFiles && self.indexFiles.length) {
68 self.indexFilesRe = new RegExp(
69 u.map(self.indexFiles, function(name) {
70 return u.escapeRegExp('/' + name) + '$';
71 }).join('|'));
72 }
73 else self.indexFiles = false; // allow use as boolean, false if empty
74
75 self.serveRoutes = serveRoutes;
76 self.outputAll = outputAll; // for pub -O
77
78 scanAll(function(err) {
79 if (self.server && self.server.generator && !self.server.generator.home) {
80 log('%s static files', u.size(self.file$));
81 };
82 cb && cb(err, self.file$);
83 });
84
85 return;
86
87 //--//--//--//--//--//--//--//--//--//--//--//--//--//
88
89 // deploy middleware and initialize watches
90 // note: this may be called before scanAll() completes
91 function serveRoutes(server) {
92 self.server = server;
93 server.app.use(serve);
94 server.app.get('/admin/statics', function(req, res) { res.send(Object.keys(self.file$)); })
95 watchAll();
96 return self; // chainable
97 }
98
99
100 // scan each staticPath
101 // no error propagation, just log(err)
102 function scanAll(cb) {
103 var done = u.after(staticPaths.length, u.maybe(cb));
104 u.each(staticPaths, function(sp) {
105 scan(sp, function(err) {
106 done();
107 });
108 });
109 }
110
111 function watchAll() {
112 u.each(staticPaths, function(sp) {
113 if (sp.watch && !opts['no-watch']) {
114 watch(sp, u.throttleMs(function(evt, path) {
115 log('static %s %s', evt, path);
116 scan(sp, function() {
117 if (self.server && self.server.generator) {
118 self.server.generator.reload();
119 }
120 });
121 }, sp.throttle || opts.throttleReload || '10s'));
122 }
123 });
124 }
125
126 // repeatable scan for single staticPath
127 function scan(sp, cb) {
128 cb = u.onceMaybe(cb);
129 var timer = u.timer();
130 sp.route = sp.route || '/';
131
132 var src = sp.src;
133
134 // only construct src, defaults, sendOpts etc. once
135 if (!src) {
136 sp.depth = sp.depth || 5;
137 sp.maxAge = 'maxAge' in sp ? sp.maxAge : '10m';
138 sp.includeBinaries = true;
139 src = sp.src = fsbase(sp);
140 if (src.isfile()) { sp.depth = 1; }
141 sp.sendOpts = u.assign(
142 u.pick(sp, 'maxAge', 'lastModified', 'etag'),
143 { dotfiles:'ignore',
144 index:false, // handled at this level
145 extensions:false, // ditto
146 root:src.path } );
147 }
148
149 src.listfiles(function(err, files) {
150 if (err) return cb(log(err));
151 sp.files = files;
152 self.scanCnt++;
153 mapAllFiles();
154 debug('static scan %s-deep %sms %s', sp.depth, timer(), sp.path.replace(/.*\/node_modules\//g, ''));
155 debug(files.length > 10 ? '[' + files.length + ' files]' : u.pluck(files, 'filepath'));
156 cb();
157 });
158 }
159
160 // recompute self.file$ hash of reqPath -> {sp, file}
161 function mapAllFiles() {
162 var file$ = {};
163 var indexFileSlash = self.trailingSlash ? '/' : '';
164 var dfile = '';
165
166 // use reverse list so that first statics in config win e.g. over packages
167 u.each(staticPathsRev, function(sp) {
168 u.each(sp.files, function(entry) {
169 var file = entry.filepath;
170 var reqPath;
171 if (self.indexFiles && self.indexFilesRe.test(file)) {
172 var shortPath = path.join(sp.route, file.replace(self.indexFilesRe, indexFileSlash));
173 if (!file$[shortPath]) {
174 reqPath = shortPath;
175 }
176 }
177 reqPath = reqPath || path.join(sp.route, file);
178
179 // only start logging dups on the last initial scan
180 if (file$[reqPath] && self.scanCnt >= staticPathsRev.length) {
181 log('duplicate static %s\n %s\n %s', file, file$[reqPath].sp.path, sp.path);
182 }
183 file$[reqPath] = {sp:sp, file:file}; // map reqPath to spo
184 if (/^\/[^\/]+\.(htm|html)$/i.test(reqPath)) { dfile = reqPath; }
185 });
186 });
187
188 // replace old map with recomputed map
189 self.file$ = file$;
190 self.defaultFile = dfile;
191 }
192
193 // only serve files in self.file$
194 function serve(req, res, next) {
195 if (req.method !== 'GET' && req.method !== 'HEAD') return next();
196
197 // surprisingly (bug?) express does not auto-decode req.path
198 var reqPath = decodeURI(req.path);
199
200 var file$ = self.file$;
201
202 // try straight match
203 var spo = file$[reqPath];
204
205 if (!spo && !/\/$|\.[^\/]+$/.test(reqPath)) {
206
207 // try adding trailing / and redirect if found
208 if (self.trailingSlash) {
209 var redir = reqPath + '/';
210 spo = file$[redir];
211 if (spo) {
212 debug('static redirect %s %s', reqPath, redir);
213 return res.redirect(302, redir); // use 302 to avoid browser redir caching
214 }
215 }
216
217 // try extensions
218 if (!spo && self.extensions) {
219 for (var i=0; i<self.extensions.length; i++) {
220 if (spo = file$[reqPath + self.extensions[i]]) break;
221 }
222 }
223 }
224
225 if (!spo) return next(); // give up
226
227 if (self.noHtmlExtensions && !path.extname(spo.file)) {
228 res.setHeader('Content-Type', 'text/html; charset=utf-8');
229 }
230
231 debug('static %s%s', reqPath, (reqPath !== spo.file ? ' -> ' + spo.file : ''));
232
233 var doit = function() { send(req, spo.file, spo.sp.sendOpts).pipe(res); }
234
235 if (spo.sp.delay) return u.delay(doit, u.ms(spo.sp.delay));
236
237 doit();
238 }
239
240 // copy static files to defaultOutput preserving reqPath routes
241 // no error propagation, just log(err)
242 function outputAll(cb) {
243 cb = u.maybe(cb);
244
245 var count = u.size(self.file$);
246 var result = [];
247
248 if (!defaultOutput || !count) return cb(log('statics.outputAll: no output'));
249
250 var done = u.after(count, function() {
251 log('output %s %s static files', defaultOutput.path, result.length);
252 cb(result)
253 });
254
255 var omit = defaultOutput.omitRoutes;
256 if (omit && !u.isArray(omit)) { omit = [omit]; }
257
258 // TODO: re-use similar filter in generator.output
259 var filterRe = new RegExp('^(/admin/|/server/' +
260 (opts.editor ? '' : '|/pub/') +
261 (omit ? '|' + u.map(omit, u.escapeRegExp).join('|') : '') +
262 ')');
263
264 u.each(self.file$, function(spo, reqPath) {
265 if (filterRe.test(reqPath)) return done();
266
267 var src = path.join(spo.sp.src.path, spo.file);
268 var dest = path.join(defaultOutput.path, spo.sp.route, spo.file);
269
270 // copy will create dirs if necessary
271 fs.copy(src, dest, function(err) {
272 if (err) return done(log(err));
273 result.push(dest);
274 done();
275 });
276
277 });
278 return self; // chainable
279 }
280
281}