UNPKG

aws-crt

Version:

NodeJS/browser bindings to the aws-c-* libraries

641 lines (572 loc) 19.1 kB
/* * 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')); }) } }