file-server
Version:
Simple http file server that supports files and directories
197 lines (153 loc) • 6 kB
JavaScript
const fs = require('graceful-fs');
const path = require('path');
const hashr = require('hashr');
const kgo = require('kgo');
const StreamCatcher = require('stream-catcher');
const chokidar = require('chokidar');
const watchers = {};
const cacheMaxSize = 1024 * 1000;
function cacheLengthFunction(n) {
return n.length;
}
function createReadStream(fileServer, request, response) {
return function(key) {
const readStream = fs.createReadStream(key);
readStream.on('error', fileServer.errorCallback.bind(null, request, response));
fileServer.cache.read(key, readStream);
};
}
function FileServer(errorCallback, cacheSize) {
if (!errorCallback) {
throw new Error('Must supply an error callback to File Server');
}
this.cache = new StreamCatcher({
length: cacheLengthFunction,
max: cacheSize == null ? cacheMaxSize : cacheSize,
});
this.errorCallback = function(request, response, error) {
errorCallback(error, request, response);
};
// Local watchers for programmatic closing
this.watchers = {};
}
FileServer.prototype.getFile = function(stats, fileName, mimeType, maxAge, request, response) {
const fileServer = this;
if (!stats.isFile()) {
return fileServer.errorCallback(request, response, { code: 404, message: `404: Not Found ${fileName}` });
}
const eTag = hashr.hash(fileName + stats.mtime.getTime());
response.setHeader('ETag', eTag);
response.setHeader('Cache-Control', `private, max-age=${maxAge}`);
if (request.headers && request.headers['if-none-match'] === eTag) {
response.writeHead(304);
return response.end();
}
response.setHeader('Content-Type', mimeType);
response.on('error', fileServer.errorCallback.bind(null, request, response));
if (stats.size === 0) {
return response.end();
}
fileServer.cache.write(fileName, response, createReadStream(fileServer, request, response));
};
function getStats(acceptsGzip, fileName, response, done) {
const gzipFileName = `${fileName}.gz`;
if (acceptsGzip) {
fs.stat(gzipFileName, (error, stats) => {
if (error) {
return fs.stat(fileName, (error, stats) => {
done.call(null, error, stats, fileName);
});
}
response.setHeader('Content-Encoding', 'gzip');
done(null, stats, gzipFileName);
});
return;
}
fs.stat(fileName, (error, stats) => {
done.call(null, error, stats, fileName);
});
}
FileServer.prototype.serveFile = function(fileName, mimeType = 'text/plain', maxAge = 0) {
const fileServer = this;
if (!watchers[fileName]) {
const watcher = chokidar.watch(fileName, { persistent: true, ignoreInitial: true });
watcher.on('change', () => {
fileServer.cache.del(fileName);
});
watchers[fileName] = watcher;
// Add to local instance for programmtic closing
this.watchers[fileName] = watcher;
}
if (!fileName || typeof fileName !== 'string') {
throw new Error('Must provide a fileName to serveFile');
}
return function(request, response) {
const acceptsGzip =
request.headers &&
request.headers['accept-encoding'] &&
~request.headers['accept-encoding'].indexOf('gzip');
kgo({
acceptsGzip,
fileName,
mimeType,
maxAge,
request,
response,
})
('stats', 'finalFilename', ['acceptsGzip', 'fileName', 'response'], getStats)
(['stats', 'finalFilename', 'mimeType', 'maxAge', 'request', 'response'], fileServer.getFile.bind(fileServer))
(['*'], error => {
if (error.message && ~error.message.indexOf('ENOENT')) {
return fileServer.errorCallback(request, response, {
code: 404,
message: `404: Not Found ${fileName}`,
});
}
return fileServer.errorCallback(request, response, error);
});
};
};
FileServer.prototype.serveDirectory = function(rootDirectory, mimeTypes, maxAge = 0) {
const fileServer = this;
if (!rootDirectory || typeof rootDirectory !== 'string') {
throw new Error('Must provide a rootDirectory to serveDirectory');
}
if (!mimeTypes || typeof mimeTypes !== 'object') {
throw new Error('Must provide a mimeTypes object to serveDirectory');
}
const keys = Object.keys(mimeTypes);
for (let i = 0; i < keys.length; i++) {
if (keys[i].charAt(0) !== '.') {
throw new Error(`Extension found without a leading periond ("."): ${keys[i]}`);
}
}
return function(request, response, fileName) {
if (arguments.length < 3) {
fileName = request.url.slice(1);
}
const filePath = path.join(rootDirectory, fileName);
const extention = path.extname(filePath).toLowerCase();
if (!mimeTypes[extention]) {
return fileServer.errorCallback(request, response, { code: 404, message: `404: Not Found ${filePath}` });
}
if (~path.relative(rootDirectory, filePath).indexOf('..')) {
return fileServer.errorCallback(request, response, { code: 404, message: `404: Not Found ${fileName}` });
}
fileServer.serveFile(filePath, mimeTypes[extention], maxAge)(request, response);
};
};
FileServer.prototype.close = function(onClose) {
const closePromises = [];
Object.keys(this.watchers).forEach((key) => {
const watcher = this.watchers[key];
closePromises.push(watcher.close());
delete this.watchers[key];
});
Promise.all(closePromises).then(() => {
onClose();
});
}
process.on('exit', () => {
Object.values(watchers).forEach(watcher => watcher.close());
});
module.exports = FileServer;