aws-crt
Version:
NodeJS/browser bindings to the aws-c-* libraries
641 lines (572 loc) • 19.1 kB
text/typescript
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0.
*/
/**
*
* A module containing support for creating http connections and making requests on them.
*
* @packageDocumentation
* @module http
* @mergeTarget
*/
import {
CommonHttpProxyOptions,
HttpHeader,
HttpHeaders as CommonHttpHeaders,
HttpProxyAuthenticationType,
HttpClientConnectionConnected,
HttpClientConnectionError,
HttpClientConnectionClosed,
HttpStreamComplete,
HttpStreamData,
HttpStreamError
} from '../common/http';
export { HttpHeader, HttpProxyAuthenticationType } from '../common/http';
import { BufferedEventEmitter } from '../common/event';
import { CrtError } from './error';
import axios = require('axios');
import { ClientBootstrap, InputStream, SocketOptions, TlsConnectionOptions } from './io';
import { fromUtf8 } from '@aws-sdk/util-utf8-browser';
/**
* A collection of HTTP headers
*
* @category HTTP
*/
export class HttpHeaders implements CommonHttpHeaders {
// Map from "header": [["HeAdEr", "value1"], ["HEADER", "value2"], ["header", "value3"]]
private headers: { [index: string]: [HttpHeader] } = {};
/** Construct from a collection of [name, value] pairs
*
* @param headers list of HttpHeader values to seat in this object
*/
constructor(headers: HttpHeader[] = []) {
for (const header of headers) {
this.add(header[0], header[1]);
}
}
/**
* Fetches the total length of all headers
*
* @returns the total length of all headers
*/
get length(): number {
let length = 0;
for (let key in this.headers) {
length += this.headers[key].length;
}
return length;
}
/**
* Add a name/value pair
* @param name The header name
* @param value The header value
*/
add(name: string, value: string) {
let values = this.headers[name.toLowerCase()];
if (values) {
values.push([name, value]);
} else {
this.headers[name.toLowerCase()] = [[name, value]];
}
}
/**
* Set a name/value pair, replacing any existing values for the name
* @param name - The header name
* @param value - The header value
*/
set(name: string, value: string) {
this.headers[name.toLowerCase()] = [[name, value]];
}
/**
* Get the list of values for the given name
* @param name - The header name to look for
* @return List of values, or empty list if none exist
*/
get_values(name: string) {
const values = [];
const values_list = this.headers[name.toLowerCase()] || [];
for (const entry of values_list) {
values.push(entry[1]);
}
return values;
}
/**
* Gets the first value for the given name, ignoring any additional values
* @param name - The header name to look for
* @param default_value - Value returned if no values are found for the given name
* @return The first header value, or default if no values exist
*/
get(name: string, default_value: string = "") {
const values = this.headers[name.toLowerCase()];
if (!values) {
return default_value;
}
return values[0][1] || default_value;
}
/**
* Removes all values for the given name
* @param name - The header to remove all values for
*/
remove(name: string) {
delete this.headers[name.toLowerCase()];
}
/**
* Removes a specific name/value pair
* @param name - The header name to remove
* @param value - The header value to remove
*/
remove_value(name: string, value: string) {
const key = name.toLowerCase();
let values = this.headers[key];
for (let idx = 0; idx < values.length; ++idx) {
const entry = values[idx];
if (entry[1] === value) {
if (values.length === 1) {
delete this.headers[key];
} else {
delete values[idx];
}
return;
}
}
}
/** Clears the entire header set */
clear() {
this.headers = {};
}
/**
* Iterator. Allows for:
* let headers = new HttpHeaders();
* ...
* for (const header of headers) { }
*/
*[Symbol.iterator]() {
for (const key in this.headers) {
const values = this.headers[key];
for (let entry of values) {
yield entry;
}
}
}
/** @internal */
_flatten(): HttpHeader[] {
let flattened = [];
for (const pair of this) {
flattened.push(pair);
}
return flattened;
}
}
/**
* Options used when connecting to an HTTP endpoint via a proxy
*
* @category HTTP
*/
export class HttpProxyOptions extends CommonHttpProxyOptions {
}
/**
* Represents a request to a web server from a client
*
* @category HTTP
*/
export class HttpRequest {
/**
* Constructor for the HttpRequest class
*
* @param method The verb to use for the request (i.e. GET, POST, PUT, DELETE, HEAD)
* @param path The URI of the request
* @param headers Additional custom headers to send to the server
* @param body The request body, in the case of a POST or PUT request
*/
constructor(
public method: string,
public path: string,
public headers = new HttpHeaders(),
public body?: InputStream) {
}
}
/**
* Represents an HTTP connection from a client to a server
*
* @category HTTP
*/
export class HttpClientConnection extends BufferedEventEmitter {
public _axios: any;
private axios_options: axios.AxiosRequestConfig;
protected bootstrap: ClientBootstrap | undefined;
protected socket_options?: SocketOptions;
protected tls_options?: TlsConnectionOptions;
protected proxy_options?: HttpProxyOptions;
/**
* Http connection constructor, signature synced to native version for compatibility
*
* @param bootstrap - (native only) leave undefined
* @param host_name - endpoint to connection with
* @param port - port to connect to
* @param socketOptions - (native only) leave undefined
* @param tlsOptions - instantiate for TLS, but actual value is unused in browse implementation
* @param proxyOptions - options to control proxy usage when establishing the connection
*/
constructor(
bootstrap: ClientBootstrap | undefined,
host_name: string,
port: number,
socketOptions?: SocketOptions,
tlsOptions?: TlsConnectionOptions,
proxyOptions?: HttpProxyOptions,
) {
super();
this.cork();
this.bootstrap = bootstrap;
this.socket_options = socketOptions;
this.tls_options = tlsOptions;
this.proxy_options = proxyOptions;
const scheme = (this.tls_options || port === 443) ? 'https' : 'http'
this.axios_options = {
baseURL: `${scheme}://${host_name}:${port}/`
};
if (this.proxy_options) {
this.axios_options.proxy = {
host: this.proxy_options.host_name,
port: this.proxy_options.port,
};
if (this.proxy_options.auth_method == HttpProxyAuthenticationType.Basic) {
this.axios_options.proxy.auth = {
username: this.proxy_options.auth_username || "",
password: this.proxy_options.auth_password || "",
};
}
}
this._axios = axios.default.create(this.axios_options);
setTimeout(() => {
this.emit('connect');
}, 0);
}
/**
* Emitted when the connection is connected and ready to start streams
*
* @event
*/
static CONNECT = 'connect';
/**
* Emitted when an error occurs on the connection
*
* @event
*/
static ERROR = 'error';
/**
* Emitted when the connection has completed
*
* @event
*/
static CLOSE = 'close';
on(event: 'connect', listener: HttpClientConnectionConnected): this;
on(event: 'error', listener: HttpClientConnectionError): this;
on(event: 'close', listener: HttpClientConnectionClosed): this;
// Override to allow uncorking on ready
on(event: string | symbol, listener: (...args: any[]) => void): this {
super.on(event, listener);
if (event == 'connect') {
setTimeout(() => {
this.uncork();
}, 0);
}
return this;
}
/**
* Make a client initiated request to this connection.
* @param request - The HttpRequest to attempt on this connection
* @returns A new stream that will deliver events for the request
*/
request(request: HttpRequest) {
return stream_request(this, request);
}
/**
* Ends the connection
*/
close() {
this.emit('close');
this._axios = undefined;
}
}
function stream_request(connection: HttpClientConnection, request: HttpRequest) {
if (request == null || request == undefined) {
throw new CrtError("HttpClientConnection stream_request: request not defined");
}
const _to_object = (headers: HttpHeaders) => {
// browsers refuse to let users configure host or user-agent
const forbidden_headers = ['host', 'user-agent'];
let obj: { [index: string]: string } = {};
for (const header of headers) {
if (forbidden_headers.indexOf(header[0].toLowerCase()) != -1) {
continue;
}
obj[header[0]] = headers.get(header[0]);
}
return obj;
}
let body = (request.body) ? (request.body as InputStream).data : undefined;
let stream = HttpClientStream._create(connection);
stream.connection._axios.request({
url: request.path,
method: request.method.toLowerCase(),
headers: _to_object(request.headers),
body: body
}).then((response: any) => {
stream._on_response(response);
}).catch((error: any) => {
stream._on_error(error);
});
return stream;
}
/**
* Listener signature for event emitted from an {@link HttpClientStream} when the http response headers have arrived.
*
* @param status_code http response status code
* @param headers the response's set of headers
*
* @category HTTP
*/
export type HttpStreamResponse = (status_code: number, headers: HttpHeaders) => void;
/**
* Represents a single http message exchange (request/response) in HTTP.
*
* NOTE: Binding either the ready or response event will uncork any buffered events and start
* event delivery
*
* @category HTTP
*/
export class HttpClientStream extends BufferedEventEmitter {
private response_status_code?: number;
private constructor(readonly connection: HttpClientConnection) {
super();
this.cork();
}
/**
* HTTP status code returned from the server.
* @return Either the status code, or undefined if the server response has not arrived yet.
*/
status_code() {
return this.response_status_code;
}
/**
* Begin sending the request.
*
* The stream does nothing until this is called. Call activate() when you
* are ready for its callbacks and events to fire.
*/
activate() {
setTimeout(() => {
this.uncork();
}, 0);
}
/**
* Emitted when the http response headers have arrived.
*
* @event
*/
static RESPONSE = 'response';
/**
* Emitted when http response data is available.
*
* @event
*/
static DATA = 'data';
/**
* Emitted when an error occurs in stream processing
*
* @event
*/
static ERROR = 'error';
/**
* Emitted when the stream has completed
*
* @event
*/
static END = 'end';
on(event: 'response', listener: HttpStreamResponse): this;
on(event: 'data', listener: HttpStreamData): this;
on(event: 'error', listener: HttpStreamError): this;
on(event: 'end', listener: HttpStreamComplete): this;
on(event: string | symbol, listener: (...args: any[]) => void): this {
return super.on(event, listener);
}
// Private helpers for stream_request()
/** @internal */
static _create(connection: HttpClientConnection) {
return new HttpClientStream(connection);
}
// Convert axios' single response into a series of events
/** @internal */
_on_response(response: any) {
this.response_status_code = response.status;
let headers = new HttpHeaders();
for (let header in response.headers) {
headers.add(header, response.headers[header]);
}
this.emit('response', this.response_status_code, headers);
let data = response.data;
if (data && !(data instanceof ArrayBuffer)) {
data = fromUtf8(data.toString());
}
this.emit('data', data);
this.emit('end');
}
// Gather as much information as possible from the axios error
// and pass it on to the user
/** @internal */
_on_error(error: any) {
let info = "";
if (error.response) {
this.response_status_code = error.response.status;
info += `status_code=${error.response.status}`;
if (error.response.headers) {
info += ` headers=${JSON.stringify(error.response.headers)}`;
}
if (error.response.data) {
info += ` data=${error.response.data}`;
}
} else {
info = "No response from server";
}
this.connection.close();
this.emit('error', new Error(`msg=${error.message}, connection=${JSON.stringify(this.connection)}, info=${info}`));
}
}
interface PendingRequest {
resolve: (connection: HttpClientConnection) => void;
reject: (error: CrtError) => void;
}
/**
* Creates, manages, and vends connections to a given host/port endpoint
*
* @category HTTP
*/
export class HttpClientConnectionManager {
private pending_connections = new Set<HttpClientConnection>();
private live_connections = new Set<HttpClientConnection>();
private free_connections: HttpClientConnection[] = [];
private pending_requests: PendingRequest[] = [];
/**
* Constructor for the HttpClientConnectionManager class. Signature stays in sync with native implementation
* for compatibility purposes (leads to some useless params)
*
* @param bootstrap - (native only) leave undefined
* @param host - endpoint to pool connections for
* @param port - port to connect to
* @param max_connections - maximum allowed connection count
* @param initial_window_size - (native only) leave as zero
* @param socket_options - (native only) leave null
* @param tls_opts - if not null TLS will be used, otherwise plain http will be used
* @param proxy_options - configuration for establishing connections through a proxy
*/
constructor(
readonly bootstrap: ClientBootstrap | undefined,
readonly host: string,
readonly port: number,
readonly max_connections: number,
readonly initial_window_size: number,
readonly socket_options?: SocketOptions,
readonly tls_opts?: TlsConnectionOptions,
readonly proxy_options?: HttpProxyOptions
) {
}
private remove(connection: HttpClientConnection) {
this.pending_connections.delete(connection);
this.live_connections.delete(connection);
const free_idx = this.free_connections.indexOf(connection);
if (free_idx != -1) {
this.free_connections.splice(free_idx, 1);
}
}
private resolve(connection: HttpClientConnection) {
const request = this.pending_requests.shift();
if (request) {
request.resolve(connection);
} else {
this.free_connections.push(connection);
}
}
private reject(error: CrtError) {
const request = this.pending_requests.shift();
if (request) {
request.reject(error);
}
}
private pump() {
if (this.pending_requests.length == 0) {
return;
}
// Try to service the request with a free connection
{
let connection = this.free_connections.pop();
if (connection) {
return this.resolve(connection);
}
}
// If there's no more room, nothing can be resolved right now
if ((this.live_connections.size + this.pending_connections.size) == this.max_connections) {
return;
}
// There's room, create a new connection
let connection = new HttpClientConnection(
new ClientBootstrap(),
this.host,
this.port,
this.socket_options,
this.tls_opts,
this.proxy_options);
this.pending_connections.add(connection);
const on_connect = () => {
this.pending_connections.delete(connection);
this.live_connections.add(connection);
this.free_connections.push(connection);
this.resolve(connection);
}
const on_error = (error: any) => {
if (this.pending_connections.has(connection)) {
// Connection never connected, error it out
return this.reject(new CrtError(error));
}
// If the connection errors after use, get it out of rotation and replace it
this.remove(connection);
this.pump();
}
const on_close = () => {
this.remove(connection);
this.pump();
}
connection.on('connect', on_connect);
connection.on('error', on_error);
connection.on('close', on_close);
}
/**
* Vends a connection from the pool
* @returns A promise that results in an HttpClientConnection. When done with the connection, return
* it via {@link release}
*/
acquire(): Promise<HttpClientConnection> {
return new Promise((resolve, reject) => {
this.pending_requests.push({
resolve: resolve,
reject: reject
});
this.pump();
});
}
/**
* Returns an unused connection to the pool
* @param connection - The connection to return
*/
release(connection: HttpClientConnection) {
this.free_connections.push(connection);
this.pump();
}
/** Closes all connections and rejects all pending requests */
close() {
this.pending_requests.forEach((request) => {
request.reject(new CrtError('HttpClientConnectionManager shutting down'));
})
}
}