UNPKG

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