@tunframework/tun
Version:
tun framework for node with typescript
501 lines (500 loc) • 17.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TunRequest = void 0;
const HttpMethod_js_1 = require("./constants/http/HttpMethod.js");
const url_1 = require("url");
const mime_js_1 = require("./constants/mime.js");
const symbol_js_1 = require("./constants/http/symbol.js");
const defaultTunRequestOptions = {
// proxy: false,
subdomainOffset: 2
};
class TunRequest {
[symbol_js_1.RAW_REQUEST];
#options;
#method;
#url;
/**
* formdata fields
* @see [bodyparser](https://github.com/tunframework/tun-bodyparser)
*/
fields = {};
_fields = {};
/**
* formdata files
* @see [bodyparser](https://github.com/tunframework/tun-bodyparser)
*/
files = {};
_files = {};
/**
* request body
* @see [bodyparser](https://github.com/tunframework/tun-bodyparser)
*/
body = {};
/**
* @example "/product/abc" matches "/product/:id", ctx.req.slugs.id === "abc"
* @see [rest-router](https://github.com/tunframework/tun-rest-router)
*/
slugs = {};
constructor(req, options) {
options = this.#options = Object.assign({}, defaultTunRequestOptions, options);
this[symbol_js_1.RAW_REQUEST] = req;
this.#method = (req.method || '');
if (options.proxy) {
this.#url = new url_1.URL(`${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}${req.url}`);
}
else {
// @ts-ignore
const protocol = req['protocol'] || 'http';
this.#url = new url_1.URL(`${protocol}://${req.headers['host']}${req.url}`);
}
}
get header() {
return this[symbol_js_1.RAW_REQUEST].headers;
}
set header(val) {
this[symbol_js_1.RAW_REQUEST].headers = val;
}
get headers() {
return this[symbol_js_1.RAW_REQUEST].headers;
}
set headers(val) {
this[symbol_js_1.RAW_REQUEST].headers = val;
}
/**
*
* http methods
*
* refers: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
*
* Get/Set request method, useful for implementing middleware such as methodOverride().
*
*/
get method() {
return this.#method;
}
set method(val) {
this.#method = val;
}
/**
* Return request Content-Length as a number when preqent, or undefined.
*/
get length() {
return this.headers['content-length']
? parseInt(this.headers['content-length'])
: undefined;
}
get url() {
return this.#url.href;
}
set url(val) {
this.#url.href = val;
}
get originalUrl() {
return this[symbol_js_1.RAW_REQUEST].url;
}
/**
* Get origin of URL, include protocol and host.
* @example http://example.com
*/
get origin() {
return this.headers['origin'];
}
/**
* Get full request URL, include protocol, host and url.
* @example http://example.com/foo/bar?q=1
*/
get href() {
return this.#url.href;
}
get path() {
return this.#url.pathname;
}
set path(val) {
const [pathname, search] = val.split('?');
this.#url.pathname = pathname;
search && (this.#url.search = search);
}
get querystring() {
if (!this.#url.search)
return '';
return this.#url.search.startsWith('?')
? this.#url.search.substring(1)
: this.#url.search;
}
set querystring(val) {
this.#url.search = val;
}
get search() {
return this.#url.search;
}
set search(val) {
this.#url.search = val;
}
/**
* Get host (hostname:port) when present.
* Supports X-Forwarded-Host when app.proxy is true,
* otherwise Host is used.
*/
get host() {
if (this.#options.proxy) {
let host = (this.headers['x-forwarded-host'] || '')
.toString()
.split(/,\s*/)[0];
if (host) {
return host;
}
}
return this.headers['host'];
}
/**
* Get hostname when present.
* Supports X-Forwarded-Host when app.proxy is true,
* otherwise Host is used.
*
* //// ignore: If host is IPv6, delegates parsing to WHATWG URL API, Note This may impact performance.
*/
get hostname() {
if (this.#options.proxy) {
let host = (this.headers['x-forwarded-host'] || '')
.toString()
.split(/,\s*/)[0];
if (host) {
return host.split(':')[0];
}
}
// @ts-ignore
return this.headers['host'].split(':')[0];
}
/**
* Get WHATWG parsed URL object.
*
* refers: https://nodebeginner.org/blog/post/nodejs-tutorial-whatwg-url-parser/
*
* url模块现在(node 8.0+)提供了一个额外的实现,它实现了标准化的WHATWG URL API,使得Node.js的url-parsing代码与Web浏览器解析URL的方式相同
*
* 原有的 'url.parse()' Nodejs 被认为
*/
get URL() {
return this.#url;
}
// Get request Content-Type void of parameters such as "charset".
get type() {
return (this.headers['content-type'] || '').toString().split(/;\s*/)[0];
}
/**
* Get request charset when present, or undefined:
* @example "utf-8"
*/
get charset() {
const prefix = 'charset=';
const part = (this.headers['content-type'] || '')
.split(';')
.find((item) => item.trim().startsWith(prefix));
return part && part.trim().substring(prefix.length);
}
get query() {
return this.#url.searchParams;
}
set query(val) {
this.#url.search = val.toString();
}
// /**
// * Check if a request cache is "fresh",
// * aka the contents have not changed.
// * This method is for cache negotiation between If-None-Match / ETag,
// * and If-Modified-Since and Last-Modified.
// * It should be referenced after setting one or more of these response headers.
// *
// * refers: https://www.zhihu.com/question/57840128
// */
// get fresh() {
// if (this.headers['cache-control'] === 'no-cache') {
// return false;
// }
// if (this.headers['if-none-match']) {
// }
// if (this.headers['if-modified-since'] && this.headers['last-modified']<='') {
// }
// return false;
// }
// get stale() {
// return !this.fresh
// }
get protocol() {
return ((this.#options.proxy && this.headers['x-forwarded-proto']) ||
this.#url.protocol.substring(0, this.#url.protocol.length - 1));
}
get secure() {
return this.protocol === 'https';
}
/**
* Request remote address. Supports X-Forwarded-For when app.proxy is true.
*/
get ip() {
let ip;
if (this.#options.proxy) {
ip = this.headers['x-forwarded-for'];
if (ip && !/unknown/i.test(ip.toString())) {
return ip.toString().split(/,\s*/)[0];
}
}
ip = this.headers['x-real-ip'];
if (ip && !/unknown/i.test(ip.toString())) {
return ip.toString().split(/,\s*/)[0];
}
return this[symbol_js_1.RAW_REQUEST].socket.remoteAddress;
}
/**
* When X-Forwarded-For is present and app.proxy is enabled an array of these ips is returned,
* ordered from upstream to downstream.
* When disabled an empty array is returned.
*
* refers: https://support.stackpath.com/hc/en-us/articles/360021658292-Getting-Real-Client-IPs-with-X-Forwarded-For
*/
get ips() {
let ips;
if (this.#options.proxy) {
ips = this.headers['x-forwarded-for'];
if (ips && !/unknown/i.test(ips.toString())) {
return ips.toString().split(/,\s*/);
}
}
ips = this.headers['x-real-ip'];
if (ips && !/unknown/i.test(ips.toString())) {
return ips.toString().split(/,\s*/);
}
return [this[symbol_js_1.RAW_REQUEST].connection.remoteAddress];
}
/**
* Return subdomains as an array.
*
* Subdomains are the dot-separated parts of the host before the main domain of the app.
* By default, the domain of the app is assumed to be the last two parts of the host.
* This can be changed by setting app.subdomainOffset.
*
* For example, if the domain is "tobi.ferrets.example.com": If app.subdomainOffset is not set,
* ctx.subdomains is ["ferrets", "tobi"].
* If app.subdomainOffset is 3, ctx.subdomains is ["tobi"].
*/
get subdomains() {
const parts = this.hostname.split('.');
return parts
.slice(0, Math.max(0, parts.length - this.#options.subdomainOffset))
.reverse();
}
/**
* Check if the incoming request contains the "Content-Type" header field,
* and it contains any of the give mime types.
* If there is no request body, null is returned.
* If there is no content type, or the match fails false is returned.
* Otherwise, it returns the matching content-type.
*/
is(...types) {
if (Array.isArray(types[0])) {
types = [...types[0]];
}
const type = this.type;
for (let i = 0; i < types.length; i++) {
const item = types[i];
if (!item)
continue;
if (item.endsWith('/*')) {
const re = new RegExp(`${item.substring(0, item.lastIndexOf('/*'))}/.+`);
if (re.test(type)) {
return type;
}
}
else if (item.indexOf('/') > -1) {
if (type === item) {
return type;
}
}
else {
const re = new RegExp(item);
if (re.test(type)) {
return type.substring(type.indexOf('/') + 1);
}
}
}
return false;
}
// begin Content Negotiation
/**
* Check if the given type(s) is acceptable,
* returning the best match when true, otherwise false.
* The type value may be one or more mime type string such as "application/json",
* the extension name such as "json",
* or an array ["json", "html", "text/plain"].
*
* refers: https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Accept
*/
accepts(...types) {
if (Array.isArray(types[0])) {
types = [...types[0]];
}
let acceptList = (this.headers['accept'] || '').toString().split(/,\s*/);
// no accept header, return the first give type
if (acceptList.length === 0 || acceptList[0] === '') {
return types[0];
}
const scoreList = types.map((type) => {
let score = 0;
const isSubType = type.indexOf('/') > -1;
acceptList.find((item) => {
const [accept, Q = '1'] = item.split(';q=');
const q = parseFloat(Q);
if (isSubType) {
if (accept === type ||
accept === `${type.substring(0, type.lastIndexOf('/'))}/*`) {
score += q;
return true;
}
}
else {
// given file extension like png,gif,html,js
if (accept.endsWith('/*')) {
const mimeList = Object.keys(mime_js_1.mimeExtMap).filter((item) => item.startsWith(accept.substring(0, accept.lastIndexOf('/'))));
const mimeExt = mimeList.find((item) => mime_js_1.mimeExtMap[item] && mime_js_1.mimeExtMap[item].indexOf(type) > -1);
if (mimeExt && typeof mimeExt === 'string') {
score += q;
return true;
}
}
else if (mime_js_1.mimeExtMap[accept] &&
mime_js_1.mimeExtMap[accept].split(' ').indexOf(type) > -1) {
score += q;
return true;
}
}
});
return score;
});
const maxScore = Math.max(...scoreList);
if (maxScore > 0) {
return types[scoreList.indexOf(maxScore)];
}
return false;
}
/**
* Check if encodings are acceptable,
* returning the best match when true, otherwise false.
* Note that you should include identity as one of the encodings!
*
* // Accept-Encoding: gzip
* ctx.acceptsEncodings('gzip', 'deflate', 'identity');
* // "gzip"
*
* ctx.acceptsEncodings(['gzip', 'deflate', 'identity']);
* // "gzip"
*
* Note that the identity encoding (which means no encoding)
* could be unacceptable if the client explicitly sends identity;q=0.
* Although this is an edge case,
* you should still handle the case where this method returns false.
*/
acceptsEncodings(...encodings) {
const { acceptHeaderParts, maxScoreAccept } = getAcceptableByQ(this.headers['accept-encoding'], encodings);
if (encodings.length === 0) {
const tmp = acceptHeaderParts.map((item) => item.split(';q=')[0]);
if (tmp.indexOf('identity') === -1) {
tmp.push('identity');
}
return tmp;
}
return maxScoreAccept;
}
/**
* Check if charsets are acceptable,
* returning the best match when true, otherwise false.
*/
acceptsCharsets(...charsets) {
const {
// acceptHeaderParts,
maxScoreAccept } = getAcceptableByQ(this.headers['accept-charset'], charsets);
if (charsets.length === 0) {
return false;
}
return maxScoreAccept;
}
/**
* Check if langs are acceptable,
* returning the best match when true,
* otherwise false.
*
* When no arguments are given all accepted languages are returned as an array
*
* refers: https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Accept-Language
* @returns return wheather (given) languages acceptable, or all accepted languages (when no arguments are given)
*/
acceptsLanguages(...langs) {
const { acceptHeaderParts, maxScoreAccept } = getAcceptableByQ(this.headers['accept-language'], langs);
if (langs.length === 0) {
return acceptHeaderParts.map((item) => item.split(/;q=\s*/)[0]);
}
return maxScoreAccept;
}
// end Content Negotiation
/**
* Check if the request is idempotent.
*
* refers: https://www.cnblogs.com/weidagang2046/archive/2011/06/04/idempotence.html
*/
idempotent() {
switch (HttpMethod_js_1.HttpMethod[this.method]) {
case HttpMethod_js_1.HttpMethod.GET:
case HttpMethod_js_1.HttpMethod.HEAD:
return true;
default:
return false;
}
}
get socket() {
return this[symbol_js_1.RAW_REQUEST].socket;
}
/**
* Return request header.
*
* field lower-kebad-case-field-name
*/
get(field) {
return this.headers[field];
}
} // TunRequest end
exports.TunRequest = TunRequest;
function getAcceptableByQ(acceptHeader, accepts) {
// flatten
const flattenAccepts = [];
accepts.forEach((item) => {
if (Array.isArray(item)) {
flattenAccepts.push(...item);
}
else {
flattenAccepts.push(item);
}
});
const acceptHeaderParts = (acceptHeader || '').toString().split(/,\s*/);
const scoreList = flattenAccepts.map((encoding) => {
let score = 0;
acceptHeaderParts.find((item) => {
const [accept, Q = '1'] = item.split(';q=');
let q = parseFloat(Q);
if (accept === encoding) {
score += q;
return true;
}
});
return score;
});
const maxScore = Math.max(...scoreList);
let maxScoreAccept = false;
if (maxScore > 0) {
maxScoreAccept = flattenAccepts[scoreList.indexOf(maxScore)].split(';q=')[0];
}
return {
acceptHeaderParts,
scoreList,
maxScore,
maxScoreAccept
};
}