superstatic
Version:
A static file server for fancy apps
193 lines (192 loc) • 6.61 kB
JavaScript
"use strict";
/**
* Copyright (c) 2022 Google LLC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const _ = require("lodash");
const mime = require("mime-types");
const path = require("path");
const onFinished = require("on-finished");
const destroy = require("destroy");
const awaitFinished = (res) => {
return new Promise((resolve) => {
onFinished(res, resolve);
});
};
/** @type Class */
const Responder = function (req, res, options) {
this.req = req;
this.res = res;
this.provider = options.provider;
this.config = options.config || {};
this.rewriters = options.rewriters || {};
this.compressor = options.compressor;
};
Responder.prototype.isNotModified = function (stats) {
if (stats.etag && stats.etag === this.req.headers["if-none-match"]) {
return true;
}
let reqModified = this.req.headers["if-modified-since"];
if (reqModified) {
reqModified = new Date(reqModified).getTime();
}
if (stats.modified && reqModified && stats.modified < reqModified) {
return true;
}
return false;
};
Responder.prototype.handle = function (item, next) {
const self = this;
return this._handle(item)
.then((responded) => {
if (!responded && next) {
next();
}
return responded;
})
.catch((err) => {
return self.handleError(err);
});
};
Responder.prototype._handle = function (item) {
if (_.isArray(item)) {
return this.handleStack(item);
}
else if (_.isString(item)) {
return this.handleFile({ file: item });
}
else if (_.isPlainObject(item)) {
if (item.file) {
return this.handleFile(item);
}
else if (item.redirect) {
return this.handleRedirect(item);
}
else if (item.rewrite) {
return this.handleRewrite(item);
}
else if (item.data) {
return this.handleData(item);
}
}
else if (_.isFunction(item)) {
return this.handleMiddleware(item);
}
return Promise.reject(new Error(JSON.stringify(item) + " is not a recognized responder directive"));
};
Responder.prototype.handleError = function (err) {
this.res.statusCode = 500;
console.log(err.stack);
this.res.end("Unexpected error occurred.");
};
Responder.prototype.handleStack = function (stack) {
const self = this;
if (stack.length) {
return this._handle(stack.shift()).then((responded) => {
return responded ? true : self.handleStack(stack);
});
}
return Promise.resolve(false);
};
Responder.prototype.handleFile = function (file) {
const self = this;
return this.provider(this.req, file.file).then((result) => {
if (!result) {
return false;
}
if (self.isNotModified(result)) {
return self.handleNotModified(result);
}
return self.handleFileStream(file, result);
});
};
Responder.prototype.handleFileStream = function (file, result) {
const self = this;
this.streamedFile = file;
this.res.statusCode = file.status || 200;
if (this.res.statusCode === 200 && file.file === this.config.errorPage) {
this.res.statusCode = 404;
}
this.res.setHeader("Content-Type", result.contentType || mime.contentType(path.extname(file.file)));
if (result.size) {
this.res.setHeader("Content-Length", result.size);
}
if (result.etag) {
this.res.setHeader("ETag", result.etag);
}
if (result.modified) {
this.res.setHeader("Last-Modified", new Date(result.modified).toUTCString());
}
if (this.compressor) {
this.compressor(this.req, this.res, () => {
result.stream.pipe(self.res);
});
}
else {
result.stream.pipe(self.res);
}
return awaitFinished(this.res).then(() => {
destroy(result.stream);
return true;
});
};
Responder.prototype.handleNotModified = function () {
this.res.statusCode = 304;
this.res.removeHeader("Content-Type");
this.res.removeHeader("Content-Length");
this.res.removeHeader("Transfer-Encoding");
this.res.end();
return true;
};
Responder.prototype.handleRedirect = function (redirect) {
this.res.statusCode = redirect.status || 301;
this.res.setHeader("Location", redirect.redirect);
this.res.setHeader("Content-Type", "text/html; charset=utf-8");
this.res.end("Redirecting to " + redirect.redirect);
return Promise.resolve(true);
};
Responder.prototype.handleMiddleware = function (middleware) {
const self = this;
return new Promise((resolve) => {
middleware(self.req, self.res, () => {
resolve(false);
});
});
};
Responder.prototype.handleRewrite = function (item) {
const self = this;
if (item.rewrite.destination) {
return self.handleFile({ file: item.rewrite.destination });
}
for (const key in this.rewriters) {
if (item.rewrite[key]) {
return this.rewriters[key](item.rewrite, this).then((result) => {
return self._handle(result);
});
}
}
return Promise.reject(new Error("Unable to find a matching rewriter for " + JSON.stringify(item.rewrite)));
};
Responder.prototype.handleData = function (data) {
this.res.statusCode = data.status || 200;
this.res.setHeader("Content-Type", data.contentType || "text/html; charset=utf-8");
this.res.end(data.data);
return Promise.resolve(true);
};
module.exports = Responder;