UNPKG

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