@foxify/http
Version:
Foxify HTTP module
810 lines • 26.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.settings = exports.DEFAULT_SETTINGS = void 0;
const assert_1 = __importDefault(require("assert"));
const http_1 = require("http");
const path_1 = require("path");
const fresh_1 = __importDefault(require("@foxify/fresh"));
const content_disposition_1 = __importDefault(require("content-disposition"));
const contentType = __importStar(require("content-type"));
const cookie = __importStar(require("cookie"));
const cookie_signature_1 = require("cookie-signature");
const escape_html_1 = __importDefault(require("escape-html"));
const on_finished_1 = __importDefault(require("on-finished"));
const send_1 = __importStar(require("send"));
const constants_1 = require("./constants");
const utils_1 = require("./utils");
/**
* Set the charset in a given Content-Type string.
*
* @param {String} type
* @param {String} charset
* @return {String}
* @api private
*/
const setCharset = (type, charset) => {
if (!type || !charset)
return type;
// Parse type
const parsed = contentType.parse(type);
// Set charset
parsed.parameters.charset = charset;
// Format type
return contentType.format(parsed);
};
/**
* Stringify JSON, like JSON.stringify, but v8 optimized, with the
* ability to escape characters that can trigger HTML sniffing.
*
* @param {StringifyT} stringifier
* @param {*} value
* @param {function} replacer
* @param {number} spaces
* @param {boolean} escape
* @returns {string}
* @private
*/
const stringify = (
// eslint-disable-next-line @typescript-eslint/default-param-last
stringifier = JSON.stringify, value, replacer, spaces, escape) => {
// TODO: v8 checks arguments.length for optimizing simple call
// https://bugs.chromium.org/p/v8/issues/detail?id=4730
if (!escape)
return stringifier(value, replacer, spaces);
return stringifier(value, replacer, spaces).replace(/[<>&]/g, (c) => {
switch (c.charCodeAt(0)) {
case 0x3c:
return "\\u003c";
case 0x3e:
return "\\u003e";
case 0x26:
return "\\u0026";
default:
return c;
}
});
};
/**
* Check if `path` looks absolute.
*
* @param {String} path
* @return {Boolean}
* @api private
*/
const isAbsolute = (path) => {
if (path.startsWith("/"))
return true;
// Windows device path
if (path[1] === ":" && (path[2] === "\\" || path[2] === "/"))
return true;
// Microsoft Azure absolute path
if (path.startsWith("\\\\"))
return true;
return false;
};
/**
* Pipe the send file stream
*/
const sendfile = (res, file, options, callback) => {
let done = false;
let streaming;
// Request aborted
function onaborted() {
if (done)
return;
done = true;
const err = new Error("Request aborted");
err.code = "ECONNABORTED";
callback(err);
}
// Directory
function ondirectory() {
if (done)
return;
done = true;
const err = new Error("EISDIR, read");
err.code = "EISDIR";
callback(err);
}
// Errors
function onerror(err) {
if (done)
return;
done = true;
callback(err);
}
// Ended
function onend() {
if (done)
return;
done = true;
callback();
}
// File
function onfile() {
streaming = false;
}
// Finished
function onfinish(err) {
if (err && err.code === "ECONNRESET") {
onaborted();
return;
}
if (err) {
onerror(err);
return;
}
if (done)
return;
setImmediate(() => {
if (streaming && !done) {
onaborted();
return;
}
if (done)
return;
done = true;
callback();
});
}
// Streaming
function onstream() {
streaming = true;
}
file.on("directory", ondirectory);
file.on("end", onend);
file.on("error", onerror);
file.on("file", onfile);
file.on("stream", onstream);
(0, on_finished_1.default)(res, onfinish);
if (options.headers) {
// Set headers on successful transfer
// eslint-disable-next-line @typescript-eslint/no-shadow
file.on("headers", (res) => {
const obj = options.headers;
const keys = Object.keys(obj);
let k;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < keys.length; i++) {
k = keys[i];
res.setHeader(k, obj[k]);
}
});
}
// Pipe
file.pipe(res);
};
/**
* Parse accept params `str` returning an
* object with `.value`, `.quality` and `.params`.
* also includes `.originalIndex` for stable sorting
*
* @param {String} str
* @param index
* @return {Object}
* @api private
*/
const acceptParams = (str, index) => {
const parts = str.split(/ *; */);
const ret = {
originalIndex: index,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
params: {},
quality: 1,
value: parts[0],
};
let pms;
for (let i = 1; i < parts.length; ++i) {
pms = parts[i].split(/ *= */);
if (pms[0] === "q")
ret.quality = parseFloat(pms[1]);
else
ret.params[pms[0]] = pms[1];
}
return ret;
};
/**
* Normalize the given `type`, for example "html" becomes "text/html".
*
* @param {String} type
* @return {Object}
* @api private
*/
const normalizeType = (type) => (~type.indexOf("/")
? acceptParams(type)
: {
value: send_1.mime.lookup(type),
params: {},
});
/**
* Normalize `types`, for example "html" becomes "text/html".
*
* @param {Array} types
* @return {Array}
* @api private
*/
const normalizeTypes = (types) => {
const ret = [];
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < types.length; ++i)
ret.push(normalizeType(types[i]));
return ret;
};
// eslint-disable-next-line import/exports-last
exports.DEFAULT_SETTINGS = {
etag: (0, utils_1.createETagGenerator)(true),
"json.escape": false,
"jsonp.callback": "callback",
};
const SETTINGS = { ...exports.DEFAULT_SETTINGS };
const hasOwnProperty = Object.prototype.hasOwnProperty;
const charsetRegExp = /;\s*charset\s*=/;
class Response extends http_1.ServerResponse {
// TODO: try eliminating the need to this function here
next;
// TODO: don't reference request in the response instance
req;
stringify;
constructor(req) {
super(req);
this.req = req;
}
/**
* Check if the request is fresh, aka
* Last-Modified and/or the ETag
* still match.
*/
get fresh() {
const req = this.req;
const method = req.method;
// GET or HEAD for weak freshness validation only
if (constants_1.METHOD.GET !== method && constants_1.METHOD.HEAD !== method)
return false;
const status = this.statusCode;
// 2xx or 304 as per rfc2616 14.26
if ((status >= constants_1.STATUS.OK && status < constants_1.STATUS.MULTIPLE_CHOICES)
|| constants_1.STATUS.NOT_MODIFIED === status) {
return (0, fresh_1.default)(req.headers, {
etag: this.get("etag"),
"last-modified": this.getHeader("last-modified"),
});
}
return false;
}
/**
* Check if the request is stale, aka
* "Last-Modified" and / or the "ETag" for the
* resource has changed.
*/
get stale() {
return !this.fresh;
}
/**
* Append additional header `field` with value `val`.
*
* @returns for chaining
* @example
* res.append("Link", ["<http://localhost/>", "<http://localhost:3000/>"]);
* @example
* res.append("Set-Cookie", "foo=bar; Path=/; HttpOnly");
* @example
* res.append("Warning", "199 Miscellaneous warning");
*/
append(field, value) {
const prev = this.get(field);
if (prev) {
// Concat the new and prev vals
value = Array.isArray(prev)
? prev.concat(value)
: Array.isArray(value)
? [prev].concat(value)
: [prev, value];
}
return this.set(field, value);
}
/**
* Set _Content-Disposition_ header to _attachment_ with optional `filename`.
*/
attachment(filename) {
if (filename)
this.type((0, path_1.extname)(filename));
return this.set("Content-Disposition", (0, content_disposition_1.default)(filename));
}
/**
* Clear cookie `name`.
*
* @returns for chaining
*/
clearCookie(name, options = {}) {
return this.cookie(name, "", {
expires: new Date(1),
path: "/",
...options,
});
}
/**
* Set _Content-Type_ response header with `type` through `mime.lookup()`
* when it does not contain "/", or set the Content-Type to `type` otherwise.
*
* @returns for chaining
* @example
* res.type(".html");
* @example
* res.type("html");
* @example
* res.type("json");
* @example
* res.type("application/json");
* @example
* res.type("png");
*/
contentType(type) {
return this.set("Content-Type", type.includes("/") ? type : send_1.mime.lookup(type));
}
/**
* Set cookie `name` to `value`, with the given `options`.
*
* Options:
* - `maxAge` max-age in milliseconds, converted to `expires`
* - `signed` sign the cookie
* - `path` defaults to "/"
*
* @returns for chaining
* @example
* // "Remember Me" for 15 minutes
* res.cookie("rememberme", "1", { expires: new Date(Date.now() + 900000), httpOnly: true });
* @example
* // save as above
* res.cookie("rememberme", "1", { maxAge: 900000, httpOnly: true })
*/
cookie(name, value, options = {}) {
options = { ...options };
const secret = this.req.secret;
const signed = options.signed;
(0, assert_1.default)(!signed || secret, "cookieParser('secret') required for signed cookies");
const typeOfValue = typeof value;
value
= typeOfValue === "string" || typeOfValue === "number"
? `${value}`
: `j:${JSON.stringify(value)}`;
if (signed)
value = `s:${(0, cookie_signature_1.sign)(value, secret)}`;
// eslint-disable-next-line no-undefined
if (options.maxAge !== undefined) {
options.expires = new Date(Date.now() + options.maxAge);
options.maxAge /= 1000;
}
if (!options.path)
options.path = "/";
return this.append("Set-Cookie", cookie.serialize(name, value, options));
}
// eslint-disable-next-line max-params
download(path, filename, options = {}, callback) {
if (typeof filename === "function") {
callback = filename;
// eslint-disable-next-line no-undefined
filename = undefined;
}
else if (typeof options === "function") {
callback = options;
options = {};
}
// Support function as second or third arg
// set Content-Disposition when file is sent
const headers = {
"Content-Disposition": (0, content_disposition_1.default)(filename ?? path),
};
// Merge user-provided headers
if (options.headers) {
const keys = Object.keys(options.headers);
let key;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < keys.length; i++) {
key = keys[i];
if (key.toLowerCase() !== "content-disposition")
headers[key] = options.headers[key];
}
}
// Merge user-provided options
options = {
...options,
headers,
};
// Resolve the full path for sendFile
const fullPath = (0, path_1.resolve)(path);
// Send file
this.sendFile(fullPath, options, callback);
}
/**
* Respond to the Acceptable formats using an `obj`
* of mime-type callbacks.
*
* This method uses `req.accepted`, an array of
* acceptable types ordered by their quality values.
* When "Accept" is not present the _first_ callback
* is invoked, otherwise the first match is used. When
* no match is performed the server responds with
* 406 "Not Acceptable".
*
* By default Foxify passes an `Error`
* with a `.status` of 406 to `next(err)`
* if a match is not made. If you provide
* a `.default` callback it will be invoked
* instead.
*
* Content-Type is set for you, however if you choose
* you may alter this within the callback using `res.type()`
* or `res.set("Content-Type", ...)`.
*
* @returns for chaining
* @example
* res.format({
* "text/plain": function() {
* res.send("hey");
* },
* "text/html": function() {
* res.send("<p>hey</p>");
* },
* "appliation/json": function() {
* res.send({ message: "hey" });
* }
* });
* @example
* // In addition to canonicalized MIME types you may
* // also use extnames mapped to these types:
*
* res.format({
* text: function() {
* res.send("hey");
* },
* html: function() {
* res.send("<p>hey</p>");
* },
* json: function() {
* res.send({ message: "hey" });
* }
* });
*/
format(types) {
const req = this.req;
const next = this.next;
const fn = types.default;
if (fn)
delete types.default;
const keys = Object.keys(types);
const key = keys.length > 0 ? req.accepts(...keys) : false;
this.vary("Accept");
if (key) {
this.set("content-type", normalizeType(key).value);
types[key](req, this, next);
}
else if (fn) {
fn(req, this, next);
}
else {
const err = new Error("Not Acceptable");
err.status = err.statusCode = 406;
err.types = normalizeTypes(keys).map(o => o.value);
next(err);
}
return this;
}
header(field, value) {
if (typeof field !== "string") {
for (const key in field) {
if (!hasOwnProperty.call(field, key))
continue;
this.header(key, field[key]);
}
return this;
}
value = Array.isArray(value) ? value.map(String) : `${value}`;
// Add charset to content-type
if (field.toLowerCase() === "content-type") {
if (!charsetRegExp.test(value)) {
const charset = send_1.mime.charsets.lookup(value.split(";")[0]);
if (charset)
value += `; charset=${charset.toLowerCase()}`;
}
}
this.setHeader(field, value);
return this;
}
/**
* Send JSON response.
*
* @example
* res.json({ user: "tj" });
*/
json(body) {
if (!this.hasHeader("content-type"))
this.setHeader("Content-Type", "application/json; charset=utf-8");
const { "json.replacer": replacer, "json.spaces": spaces, "json.escape": escape, } = SETTINGS;
return this.send(stringify(this.stringify[this.statusCode], body, replacer, spaces, escape));
}
/**
* Send JSON response with JSONP callback support.
*
* @example
* res.jsonp({ user: "tj" });
*/
jsonp(body) {
// Settings
const { "json.replacer": replacer, "json.spaces": spaces, "json.escape": escape, "jsonp.callback": callbackName, } = SETTINGS;
let str = stringify(this.stringify[this.statusCode], body, replacer, spaces, escape);
let callback = this.req.query[callbackName];
// Content-type
if (!this.get("content-type")) {
this.set("x-content-type-options", "nosniff");
this.set("content-type", "application/json");
}
// Fixup callback
if (Array.isArray(callback))
callback = callback[0];
// Jsonp
if (typeof callback === "string" && callback.length !== 0) {
this.set("x-content-type-options", "nosniff");
this.set("content-type", "text/javascript");
// Restrict callback charset
callback = callback.replace(/[^[\]\w$.]/g, "");
// Replace chars not allowed in JavaScript that are in JSON
str = str.replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
// The /**/ is a specific security mitigation for "Rosetta Flash JSONP abuse"
// the typeof check is just to reduce client error noise
str = `/**/ typeof ${callback} === 'function' && ${callback}(${str});`;
}
return this.send(str);
}
/**
* Set Link header field with the given links.
*
* @example
* res.links({
* next: "http://api.example.com/users?page=2",
* last: "http://api.example.com/users?page=5"
* });
*/
links(links) {
let link = this.get("link") ?? "";
if (link)
link += ", ";
return this.set("link", `${link}${Object.keys(links)
.map(rel => `<${links[rel]}>; rel="${rel}"`)
.join(", ")}`);
}
/**
* Set the location header to `url`.
*
* The given `url` can also be "back", which redirects
* to the _Referrer_ or _Referer_ headers or "/".
*
* @returns for chaining
* @example
* res.location("back").;
* @example
* res.location("/foo/bar").;
* @example
* res.location("http://example.com");
* @example
* res.location("../login");
*/
location(url) {
return this.set("Location",
// "back" is an alias for the referrer
(0, utils_1.encodeUrl)(url === "back" ? this.req.get("referrer") ?? "/" : url));
}
/**
* Redirect to the given `url` with optional response `status`
* defaulting to 302.
*
* The resulting `url` is determined by `res.location()`, so
* it will play nicely with mounted apps, relative paths,
* `"back"` etc.
*
* @example
* res.redirect("/foo/bar");
* @example
* res.redirect("http://example.com");
* @example
* res.redirect("http://example.com", 301);
* @example
* res.redirect("../login"); // /blog/post/1 -> /blog/login
*/
redirect(url, status = constants_1.STATUS.FOUND) {
let body = "";
// Set location header
url = this.location(url).get("Location");
// Support text/{plain,html} by default
this.format({
default: () => (body = ""),
html: () => {
const u = (0, escape_html_1.default)(url);
body = `<p>${http_1.STATUS_CODES[status]}. Redirecting to <a href="${u}">${u}</a></p>`;
},
text: () => (body = `${http_1.STATUS_CODES[status]}. Redirecting to ${url}`),
});
// Respond
this.statusCode = status;
this.set("content-length", Buffer.byteLength(body));
if (this.req.method === constants_1.METHOD.HEAD)
this.end();
else
this.end(body);
}
render(view, data, callback) {
const { view: engine } = SETTINGS;
(0, assert_1.default)(engine, "View engine is not specified");
if (typeof data === "function") {
callback = data;
// eslint-disable-next-line no-undefined
data = undefined;
}
if (!callback) {
callback = (err, str) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (err != null) {
this.next(err);
return;
}
this.send(str);
};
}
engine.render(view, data, callback);
}
/**
* Send a response.
*
* @example
* res.send(Buffer.from("wahoo"));
* @example
* res.send({ some: "json" });
* @example
* res.send("<p>some html</p>");
*/
send(body) {
let encoding;
if (typeof body === "string") {
encoding = "utf-8";
const type = this.get("content-type");
// Reflect this in content-type
if (typeof type === "string")
this.set("Content-Type", setCharset(type, encoding));
else
this.set("Content-Type", setCharset("text/html", encoding));
}
else if (Buffer.isBuffer(body)) {
if (!this.hasHeader("content-type"))
this.type("bin");
}
else if (body === null) {
body = "";
// eslint-disable-next-line no-undefined
}
else if (body !== undefined) {
return this.json(body);
}
// eslint-disable-next-line no-undefined
if (body !== undefined) {
const { etag } = SETTINGS;
if (etag && !this.hasHeader("etag")) {
const generatedETag = etag(body, encoding);
if (generatedETag)
this.setHeader("ETag", generatedETag);
}
}
// Freshness
if (this.fresh)
this.statusCode = constants_1.STATUS.NOT_MODIFIED;
const { statusCode } = this;
// Strip irrelevant headers
if (constants_1.STATUS.NO_CONTENT === statusCode
|| constants_1.STATUS.NOT_MODIFIED === statusCode) {
this.removeHeader("content-type");
this.removeHeader("content-length");
this.removeHeader("transfer-encoding");
body = "";
}
// Skip body for HEAD
if (this.req.method === constants_1.METHOD.HEAD)
this.end();
else
this.end(body, encoding);
return this;
}
sendFile(path, options = {}, callback) {
if (typeof options === "function") {
callback = options;
options = {};
}
(0, assert_1.default)(path, "Argument 'path' is required to res.sendFile");
// Support function as second arg
(0, assert_1.default)(options.root || isAbsolute(path), "Path must be absolute or specify root to res.sendFile");
// Create file stream
const pathname = encodeURI(path);
const file = (0, send_1.default)(this.req, pathname, options);
// Transfer
sendfile(this, file, options, (err) => {
if (callback) {
callback(err);
return;
}
if (err && err.code === "EISDIR") {
this.next();
return;
}
// Next() all but write errors
if (err && err.code !== "ECONNABORTED" && err.syscall !== "write")
this.next(err);
});
}
/**
* Send given HTTP status code.
*
* Sets the response status to `statusCode` and the body of the
* response to the standard description from node's http.STATUS_CODES
* or the statusCode number if no description.
*
* @example
* res.sendStatus(200);
*/
sendStatus(statusCode) {
return this.status(statusCode)
.type("txt")
.send(http_1.STATUS_CODES[statusCode] ?? `${statusCode}`);
}
/**
* Set response status code.
*
* @example
* res.status(500);
*/
status(statusCode) {
this.statusCode = statusCode;
return this;
}
/**
* Add `field` to Vary. If already present in the Vary set, then
* this call is simply ignored.
*
* @returns for chaining
*/
vary(field = []) {
return (0, utils_1.vary)(this, field);
}
}
Response.prototype.type = Response.prototype.contentType;
Response.prototype.set = Response.prototype.header;
Response.prototype.get = Response.prototype.getHeader;
exports.default = Response;
// eslint-disable-next-line @typescript-eslint/no-shadow
function settings(settings = exports.DEFAULT_SETTINGS) {
Object.assign(SETTINGS, settings);
}
exports.settings = settings;
//# sourceMappingURL=Response.js.map