UNPKG

@foxify/http

Version:
810 lines 26.1 kB
"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