UNPKG

filestack-js

Version:

Official JavaScript library for Filestack

382 lines (308 loc) 11.9 kB
/* * Copyright (c) 2018 by Filestack * Some rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as url from 'url'; import * as zlib from 'zlib'; import Debug from 'debug'; import { AdapterInterface } from './interface'; import { getVersion } from '../../utils'; import * as Stream from 'stream'; import { FsRequestOptions, FsResponse } from '../types'; import * as utils from '../utils'; import { prepareData, parseResponse, combineURL, set as setHeader, normalizeHeaders } from './../helpers'; import { FsRequestErrorCode, FsRequestError } from '../error'; import { FsHttpMethod } from './../types'; const HTTPS_REGEXP = /https:?/; const HTTP_CHUNK_SIZE = 16 * 1024; const MAX_REDIRECTS = 10; const CANCEL_CLEAR = `FsCleanMemory`; const debug = Debug('fs:request:http'); /** * Writable stream thats overwrap http request for progress event * * @class HttpWritableStream * @extends {Stream.Writable} */ class HttpWritableStream extends Stream.Writable { private request; constructor(req, opts = {}) { super(opts); this.request = req; req.once('drain', () => this.emit('drain')); } _write(chunk: any, encoding?: string, cb?: (error: Error | null | undefined) => void): void { this.request.write(chunk, encoding, cb); } end(chunk?: any, encoding?: any, cb?: any): this { super.end(chunk, encoding, cb); return this; } _final(cb: (error?: Error | null | undefined) => void): void { this.request.end(); cb(); } } /** * Node http request class * * @export * @class HttpAdapter * @implements {AdapterInterface} */ export class HttpAdapter implements AdapterInterface { private redirectHoops = 0; private redirectPaths = []; /** * do request based on configuration * * @param {FsRequestOptions} config * @returns * @memberof HttpAdapter */ request(config: FsRequestOptions) { // if this option is unspecified set it by default if (typeof config.filestackHeaders === 'undefined') { config.filestackHeaders = true; } config.headers = normalizeHeaders(config.headers); let { data, headers } = prepareData(config); headers = setHeader(headers, 'user-agent', `filestack-request/${getVersion()}`); // for now we are not using streams if (data) { debug('Request data %O', data); if (!Buffer.isBuffer(data)) { if (!utils.isString(data)) { return Promise.reject(new FsRequestError('Data must be a string, JSON or a Buffer', config)); } data = Buffer.from(data, 'utf-8'); } headers = setHeader(headers, 'content-length', data.length, true); } // HTTP basic authentication let auth; if (config.auth) { if (!config.auth.username || config.auth.username.length === 0) { return Promise.reject(new FsRequestError(`Basic auth: username is required ${config.auth}`, config)); } auth = `${config.auth.username}:${config.auth.password}`; } // Parse url let parsed = url.parse(config.url); // try to add default https protocol if (!parsed.protocol) { parsed = url.parse(`https://${config.url}`); } /* istanbul ignore next: just be sure that the host is parsed correctly, not needed to test */ if (!parsed.host) { return Promise.reject(new FsRequestError(`Cannot parse provided url ${config.url}`, config)); } // normalize auth header if (auth && headers.Authorization) { delete headers.Authorization; } const isHttpsRequest = HTTPS_REGEXP.test(parsed.protocol); const agent = isHttpsRequest ? require('https') : require('http'); const options = { path: combineURL(parsed.path, config.params), host: parsed.host, port: parsed.port, protocol: parsed.protocol, method: config.method.toUpperCase(), headers: headers, agent: new agent.Agent(), auth: auth, }; debug('Starting %s request with options %O', isHttpsRequest ? 'https' : 'http', options); return new Promise<FsResponse>((resolve, reject): any => { let req; let cancelListener; if (config.cancelToken) { cancelListener = config.cancelToken.on('cancel', (reason) => { // cleanup handler cancelListener = null; // do nothing if promise is resolved by system if (reason && reason.message === CANCEL_CLEAR) { return; } /* istanbul ignore next: if request is done cancel token should not throw any error */ if (req) { req.abort(); req = null; } debug('Request canceled by user %s, config: %O', reason, config); return reject(new FsRequestError(`Request aborted. Reason: ${reason}`, config, null, FsRequestErrorCode.ABORTED)); }); } req = agent.request(options, res => { /* istanbul ignore next: just be sure that response will not be called after request is aborted */ if (!req || req.aborted) { return reject(new FsRequestError('Request aborted', config)); } let stream = res; debug('Response statusCode: %d, Response Headers: %O', res.statusCode, res.headers); const compressHeaders = res.headers['content-encoding']; if (compressHeaders && compressHeaders.length && ['gzip', 'compress', 'deflate'].some((v) => compressHeaders.indexOf(v) > -1)) { // add the unzipper to the body stream processing pipeline stream = res.statusCode === 204 ? stream : stream.pipe(zlib.createUnzip()); // remove the content-encoding in order to not confuse downstream operations delete res.headers['content-encoding']; } let response: FsResponse = { status: res.statusCode, statusText: res.statusMessage, headers: res.headers, config, data: {}, }; // we need to follow redirect so make same request with new location if ([301, 302].indexOf(res.statusCode) > -1) { debug('Redirect received %s', res.statusCode); if (this.redirectHoops >= MAX_REDIRECTS) { return reject(new FsRequestError(`Max redirects (${this.redirectHoops}) reached. Exiting`, config, response, FsRequestErrorCode.REDIRECT)); } const url = res.headers['location']; if (!url || url.length === 0) { return reject(new FsRequestError(`Redirect header location not found`, config, response, FsRequestErrorCode.REDIRECT)); } if (this.redirectPaths.indexOf(url) > -1) { return reject(new FsRequestError(`Redirect loop detected at url ${url}`, config, response, FsRequestErrorCode.REDIRECT)); } this.redirectPaths.push(url); this.redirectHoops++; // free resources res = undefined; req = undefined; debug('Redirecting request to %s (hoop-count: %d)', url, this.redirectHoops); return resolve(this.request(Object.assign({}, config, { url }))); } let responseBuffer = []; stream.on('data', chunk => responseBuffer.push(chunk)); /* istanbul ignore next: its hard to test socket events with jest and nock - tested manually */ stream.on('error', err => { res = undefined; req = undefined; responseBuffer = undefined; debug('Request error: Aborted %O', err); if (req.aborted) { return; } // clear cancel token to avoid memory leak if (cancelListener) { config.cancelToken.removeListener(cancelListener); } return reject(new FsRequestError(err.message, config, null, FsRequestErrorCode.NETWORK)); }); stream.on('end', async () => { // clear cancel token to avoid memory leak if (cancelListener) { config.cancelToken.removeListener(cancelListener); } // check if there is any response data inside if (res.statusCode !== 204) { // prepare response response.data = Buffer.concat(responseBuffer); response = await parseResponse(response); } else { response.data = null; } // free resources res = undefined; req = undefined; responseBuffer = undefined; if (500 <= response.status && response.status <= 599) { // server error throw debug('Server error(5xx) - %O', response); return reject(new FsRequestError(`Server error ${url}`, config, response, FsRequestErrorCode.SERVER)); } else if (400 <= response.status && response.status <= 499) { debug('Request error(4xx) - %O', response); return reject(new FsRequestError(`Request error ${url}`, config, response, FsRequestErrorCode.REQUEST)); } debug('Request ends: %O', response); return resolve(response); }); }); if (config.timeout) { req.setTimeout(config.timeout, () => { req.abort(); if (cancelListener) { config.cancelToken.removeListener(cancelListener); } return reject(new FsRequestError('Request timeout', config, null, FsRequestErrorCode.TIMEOUT)); }); } req.on('error', err => { if (cancelListener) { config.cancelToken.removeListener(cancelListener); } if (!req || req.aborted) { return; } debug('Request error: %s - %O', err, err.code); return reject(new FsRequestError(`Request error: ${err.code}`, config, null, FsRequestErrorCode.NETWORK)); }); if (Buffer.isBuffer(data) && ['POST', 'PUT'].indexOf(config.method) > -1) { return this.bufferToChunks(data).pipe(this.getProgressMonitor(config, data.length)).pipe(new HttpWritableStream(req)); } req.end(data); }); } /** * Monitor and emit progress event if needed * * @private * @memberof HttpAdapter */ private getProgressMonitor = (config, total) => { let loaded = 0; const progress = new Stream.Transform(); progress._transform = (chunk, encoding, cb) => { if (typeof config.onProgress === 'function' && [FsHttpMethod.POST, FsHttpMethod.PUT].indexOf(config.method) > -1) { loaded += chunk.length; config.onProgress({ lengthComputable: true, loaded, total, }); } cb(null, chunk); }; return progress; } /** * Convert buffer to stream * * @private * @param {*} buffer * @returns {Stream.Readable} * @memberof HttpAdapter */ private bufferToChunks(buffer): Stream.Readable { const chunking = new Stream.Readable(); const totalLength = buffer.length; const remainder = totalLength % HTTP_CHUNK_SIZE; const cutoff = totalLength - remainder; for (let i = 0; i < cutoff; i += HTTP_CHUNK_SIZE) { const chunk = buffer.slice(i, i + HTTP_CHUNK_SIZE); chunking.push(chunk); } if (remainder > 0) { const remainderBuffer = buffer.slice(-remainder); chunking.push(remainderBuffer); } chunking.push(null); return chunking; } }