lib/runtime/request.ts
import { Dict } from '../utils/types';
import Route from './route';
import { AppConfig } from './config';
import { constant } from 'lodash';
import * as accepts from 'accepts';
import { isIP } from 'net';
import { TLSSocket } from 'tls';
import * as typeis from 'type-is';
import * as http from 'http';
import * as parseRange from 'range-parser';
import * as parse from 'parseurl';
import * as proxyaddr from 'proxy-addr';
import * as uuid from 'uuid';
import * as url from 'url';
/**
* The Request class represents an incoming HTTP request (specifically, Node's
* IncomingMessage).
*
* @package runtime
* @since 0.1.0
*/
export default class Request {
/**
* A UUID generated unqiue to this request. Useful for tracing a request
* through the application.
*
* @since 0.1.0
*/
id: string = uuid.v4();
/**
* The route parser route that was matched
*
* @since 0.1.0
*/
route: Route;
/**
* The name of the original action that was invoked - useful for error
* actions to create helpful debug messages.
*
* @since 0.1.0
*/
_originalAction: string;
/**
* The underlying HTTP server's IncomingMessage instance
*
* @since 0.1.0
*/
incomingMessage: http.IncomingMessage;
/**
* A subset of the app config, the `config.server` namespace
*
* @since 0.1.0
*/
config: AppConfig['server'];
/**
* The uppercase method name for the request, i.e. GET, POST, HEAD
*
* @since 0.1.0
*/
get method(): string {
return this.incomingMessage.method.toUpperCase();
}
/**
* The requested path name
*
* @since 0.1.0
*/
get path(): string {
return parse(this.incomingMessage).pathname;
}
/**
* The params extracted from the router's dynamic segments
*
* @since 0.1.0
*/
params: any;
/**
* The query string, parsed into an object
*
* @since 0.1.0
*/
get query(): Dict<string | string[]> {
return url.parse(this.incomingMessage.url, true).query;
}
/**
* The headers for the incoming request
*
* @since 0.1.0
*/
get headers(): Dict<string | string[]> {
return this.incomingMessage.headers;
}
/**
* 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
* config.server.subdomainOffset
*
* For example, if the domain is "tobi.ferrets.example.com": If the subdomain
* offset is not set, req.subdomains is `["ferrets", "tobi"]`. If the
* subdomain offset is 3, req.subdomains is `["tobi"]`.
*
* @since 0.1.0
*/
get subdomains(): string[] {
let hostname = this.hostname;
if (!hostname) {
return [];
}
let offset = this.config.subdomainOffset;
let subdomains = !isIP(hostname) ? hostname.split('.').reverse() : [ hostname ];
return subdomains.slice(offset == null ? 2 : offset);
}
/**
* Return the protocol string "http" or "https" when requested with TLS. When
* the "server.trustProxy" setting trusts the socket address, the
* "X-Forwarded-Proto" header field will be trusted and used if present.
*
* If you're running behind a reverse proxy that supplies https for you this
* may be enabled.
*
* @since 0.1.0
*/
get protocol(): 'http' | 'https' {
let rawProtocol: 'http' | 'https' = (<TLSSocket>this.incomingMessage.connection).encrypted ? 'https' : 'http';
let ip = this.incomingMessage.connection.remoteAddress;
let trustProxyConfig = this.config.trustProxy || constant(false);
if (trustProxyConfig) {
let trustProxy: (addr?: string, i?: number) => boolean;
if (typeof trustProxyConfig !== 'function') {
trustProxy = proxyaddr.compile(trustProxyConfig);
} else {
trustProxy = trustProxyConfig;
}
if (trustProxy(ip, 0)) {
let proxyClaimedProtocol = this.getHeader('X-Forwarded-Proto') || rawProtocol;
return <'http' | 'https'>proxyClaimedProtocol.split(/\s*,\s*/)[0];
}
}
return rawProtocol;
}
/**
* Check if the request was an _XMLHttpRequest_.
*
* @since 0.1.0
*/
get xhr(): boolean {
let val = this.getHeader('X-Requested-With') || '';
return val.toLowerCase() === 'xmlhttprequest';
}
/**
* Parse the "Host" header field to a hostname.
*
* When the "trust proxy" setting trusts the socket address, the
* "X-Forwarded-Host" header field will be trusted.
*
* @since 0.1.0
*/
get hostname(): string {
let host = this.getHeader('X-Forwarded-Host');
let ip = this.incomingMessage.socket.remoteAddress;
let trustProxyConfig = this.config.trustProxy || constant(false);
let trustProxy: (addr?: string, i?: number) => boolean;
if (typeof trustProxyConfig !== 'function') {
trustProxy = proxyaddr.compile(trustProxyConfig);
} else {
trustProxy = trustProxyConfig;
}
if (!host || !trustProxy(ip, 0)) {
host = this.getHeader('Host');
}
if (!host) {
return;
}
// IPv6 literal support
let offset = host[0] === '[' ? host.indexOf(']') + 1 : 0;
let index = host.indexOf(':', offset);
return index !== -1 ? host.substring(0, index) : host;
}
/**
* Return the remote address from the trusted proxy.
*
* The is the remote address on the socket unless "trust proxy" is set.
*
* @since 0.1.0
*/
get ip(): string {
let trustProxyConfig = this.config.trustProxy || constant(false);
return proxyaddr(this.incomingMessage, trustProxyConfig);
}
/**
* When "trust proxy" is set, trusted proxy addresses + client.
*
* For example if the value were "client, proxy1, proxy2" you would receive
* the array `["client", "proxy1", "proxy2"]` where "proxy2" is the furthest
* down-stream and "proxy1" and "proxy2" were trusted.
*
* @since 0.1.0
*/
get ips(): string[] {
let trustProxyConfig = this.config.trustProxy || constant(false);
let ips = proxyaddr.all(this.incomingMessage, trustProxyConfig);
ips.reverse().pop();
return ips;
}
/**
* Does this request have a request body?
*/
get hasBody(): boolean {
return typeis.hasBody(this.incomingMessage);
}
constructor(incomingMessage: http.IncomingMessage, serverConfig?: AppConfig['server']) {
this.incomingMessage = incomingMessage;
this.config = serverConfig || {};
}
/**
* Return request header.
*
* The `Referrer` header field is special-cased, both `Referrer` and
* `Referer` are interchangeable.
*
* Examples:
*
* req.get('Content-Type'); // => "text/plain"
*
* req.get('content-type'); // => "text/plain"
*
* req.get('Something'); // => undefined
*
* Aliased as `req.header()`.
* @since 0.1.0
*/
getHeader(name: string): string;
getHeader(name: 'set-cookie' | 'Set-cookie' | 'Set-Cookie'): string[];
getHeader(name: 'set-cookie' | 'Set-cookie' | 'Set-Cookie' | string): string | string[] {
return this.incomingMessage.headers[name.toLowerCase()];
}
/**
* Check if the given `type(s)` is acceptable, returning the best match when
* true, otherwise `undefined`, in which case you should respond with 406
* "Not Acceptable".
*
* The `type` value may be a single MIME type string such as
* "application/json", an extension name such as "json", a comma-delimited
* list such as "json, html, text/plain", an argument list such as `"json",
* "html", "text/plain"`, or an array `["json", "html", "text/plain"]`. When
* a list or array is given, the _best_ match, if any is returned.
*
* Examples:
*
* // Accept: text/html
* req.accepts('html');
* // => "html"
*
* // Accept: text/*, application/json
* req.accepts('html');
* // => "html"
* req.accepts('text/html');
* // => "text/html"
* req.accepts('json, text');
* // => "json"
* req.accepts('application/json');
* // => "application/json"
*
* // Accept: text/*, application/json
* req.accepts('image/png');
* req.accepts('png');
* // => undefined
*
* // Accept: text/*;q=.5, application/json
* req.accepts(['html', 'json']);
* req.accepts('html', 'json');
* req.accepts('html, json');
* // => "json"
*
* @since 0.1.0
*/
accepts(): string[];
accepts(...type: string[]): string[] | string | false;
accepts(...type: string[]): string[] | string | false {
let accept = accepts(this.incomingMessage);
return accept.types(...type);
}
/**
* Check if the given `encoding`s are accepted.
*
* @since 0.1.0
*/
acceptsEncodings(): string[];
acceptsEncodings(...encoding: string[]): string | false;
acceptsEncodings(...encoding: string[]): string[] | string | false {
let accept = accepts(this.incomingMessage);
// <any> is needed here because of incorrect types
// see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/23395
return (<any>accept.encodings)(...encoding);
}
/**
* Check if the given `charset`s are acceptable, otherwise you should respond
* with 406 "Not Acceptable".
*
* @since 0.1.0
*/
acceptsCharsets(): string[];
acceptsCharsets(...charset: string[]): string | false;
acceptsCharsets(...charset: string[]): string[] | string | false {
let accept = accepts(this.incomingMessage);
// <any> is needed here because of incorrect types
// see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/23395
return (<any>accept.charsets)(...charset);
}
/**
* Check if the given `lang`s are acceptable, otherwise you should respond
* with 406 "Not Acceptable".
*
* @since 0.1.0
*/
acceptsLanguages(): string[];
acceptsLanguages(...lang: string[]): string | false;
acceptsLanguages(...lang: string[]): string[] | string | false {
let accept = accepts(this.incomingMessage);
// <any> is needed here because of incorrect types
// see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/23395
return (<any>accept.languages)(...lang);
}
/**
* Parse Range header field, capping to the given `size`.
*
* Unspecified ranges such as "0-" require knowledge of your resource length.
* In the case of a byte range this is of course the total number of bytes. If
* the Range header field is not given `undefined` is returned, `-1` when
* unsatisfiable, and `-2` when syntactically invalid.
*
* When ranges are returned, the array has a "type" property which is the type
* of range that is required (most commonly, "bytes"). Each array element is
* an object with a "start" and "end" property for the portion of the range.
*
* The "combine" option can be set to `true` and overlapping & adjacent ranges
* will be combined into a single range.
*
* NOTE: remember that ranges are inclusive, so for example "Range: users=0-3"
* should respond with 4 users when available, not 3.
*
* @since 0.1.0
*/
range(size: number, options?: parseRange.Options): parseRange.Result | parseRange.Ranges {
let range = this.getHeader('Range');
if (!range) {
return;
}
return parseRange(size, range, options);
}
/**
* Check if the incoming request contains the "Content-Type" header field,
* and it contains the give mime `type`.
*
* Examples:
*
* // With Content-Type: text/html; charset=utf-8
* req.is('html');
* req.is('text/html');
* req.is('text/*');
* // => true
*
* // When Content-Type is application/json
* req.is('json');
* req.is('application/json');
* req.is('application/*');
* // => true
*
* req.is('html');
* // => false
*
* @since 0.1.0
*/
is(...types: string[]): string | false {
if (Array.isArray(types[0])) {
types = <any>types[0];
}
return typeis(this.incomingMessage, types);
}
}