http2.js 9.18 KB
"use strict";
// Copyright 2020 Google LLC
// 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.
Object.defineProperty(exports, "__esModule", { value: true });
exports.closeSession = exports.request = exports.sessions = void 0;
const http2 = require("http2");
const zlib = require("zlib");
const url_1 = require("url");
const qs = require("qs");
const extend = require("extend");
const stream_1 = require("stream");
const util = require("util");
const process = require("process");
const common_1 = require("gaxios/build/src/common");
const { HTTP2_HEADER_CONTENT_ENCODING, HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_METHOD, HTTP2_HEADER_PATH, HTTP2_HEADER_STATUS, } = http2.constants;
const DEBUG = !!process.env.HTTP2_DEBUG;
/**
 * List of sessions current in use.
 * @private
 */
exports.sessions = {};
let warned = false;
/**
 * Public method to make an http2 request.
 * @param config - Request options.
 */
async function request(config) {
    // Make sure users know this API is unstable
    if (!warned) {
        const message = `
      The HTTP/2 API in googleapis is unstable! This is an early implementation
      that should not be used in production.  It may change in unpredictable
      ways. Please only use this for experimentation.
    `;
        process.emitWarning(message, 'GOOG_HTTP2');
        warned = true;
    }
    const opts = extend(true, {}, config);
    opts.validateStatus = opts.validateStatus || validateStatus;
    opts.responseType = opts.responseType || 'json';
    const url = new url_1.URL(opts.url);
    // Check for an existing session to this host, or go create a new one.
    const sessionData = _getClient(url.host);
    // Since we're using this session, clear the timeout handle to ensure
    // it stays in memory and connected for a while further.
    if (sessionData.timeoutHandle !== undefined) {
        clearTimeout(sessionData.timeoutHandle);
    }
    // Assemble the querystring based on config.params.  We're using the
    // `qs` module to make life a little easier.
    let pathWithQs = url.pathname;
    if (config.params && Object.keys(config.params).length > 0) {
        const q = qs.stringify(opts.params);
        pathWithQs += `?${q}`;
    }
    // Assemble the headers based on basic HTTP2 primitives (path, method) and
    // custom headers sent from the consumer.  Note: I am using `Object.assign`
    // here making the assumption these objects are not deep.  If it turns out
    // they are, we may need to use the `extend` npm module for deep cloning.
    const headers = Object.assign({}, opts.headers, {
        [HTTP2_HEADER_PATH]: pathWithQs,
        [HTTP2_HEADER_METHOD]: config.method || 'GET',
    });
    // NOTE: This is working around an upstream bug in `apirequest.ts`. The
    // request path assumes that the `content-type` header is going to be set in
    // the underlying HTTP Client. This hack provides bug for bug compatability
    // with this bug in gaxios:
    // https://github.com/googleapis/gaxios/blob/master/src/gaxios.ts#L202
    if (!headers[HTTP2_HEADER_CONTENT_TYPE]) {
        if (opts.responseType !== 'text') {
            headers[HTTP2_HEADER_CONTENT_TYPE] = 'application/json';
        }
    }
    const res = {
        config,
        request: {},
        headers: [],
        status: 0,
        data: {},
        statusText: '',
    };
    const chunks = [];
    const session = sessionData.session;
    let req;
    return new Promise((resolve, reject) => {
        try {
            req = session
                .request(headers)
                .on('response', headers => {
                res.headers = headers;
                res.status = Number(headers[HTTP2_HEADER_STATUS]);
                let stream = req;
                if (headers[HTTP2_HEADER_CONTENT_ENCODING] === 'gzip') {
                    stream = req.pipe(zlib.createGunzip());
                }
                if (opts.responseType === 'stream') {
                    res.data = stream;
                    resolve(res);
                    return;
                }
                stream
                    .on('data', d => {
                    chunks.push(d);
                })
                    .on('error', err => {
                    reject(err);
                    return;
                })
                    .on('end', () => {
                    const buf = Buffer.concat(chunks);
                    // eslint-disable-next-line @typescript-eslint/no-explicit-any
                    let data = buf;
                    if (buf) {
                        if (opts.responseType === 'json') {
                            try {
                                data = JSON.parse(buf.toString('utf8'));
                            }
                            catch (_a) {
                                data = buf.toString('utf8');
                            }
                        }
                        else if (opts.responseType === 'text') {
                            data = buf.toString('utf8');
                        }
                        else if (opts.responseType === 'arraybuffer') {
                            data = buf.buffer;
                        }
                        res.data = data;
                    }
                    if (!opts.validateStatus(res.status)) {
                        let message = `Request failed with status code ${res.status}. `;
                        if (res.data && typeof res.data === 'object') {
                            const body = util.inspect(res.data, { depth: 5 });
                            message = `${message}\n'${body}`;
                        }
                        reject(new common_1.GaxiosError(message, opts, res));
                    }
                    resolve(res);
                    return;
                });
            })
                .on('error', e => {
                reject(e);
                return;
            });
        }
        catch (e) {
            closeSession(url);
            reject(e);
        }
        res.request = req;
        // If data was provided, write it to the request in the form of
        // a stream, string data, or a basic object.
        if (config.data) {
            if (config.data instanceof stream_1.Stream) {
                config.data.pipe(req);
            }
            else if (typeof config.data === 'string') {
                const data = Buffer.from(config.data);
                req.end(data);
            }
            else if (typeof config.data === 'object') {
                const data = JSON.stringify(config.data);
                req.end(data);
            }
        }
        // Create a timeout so the Http2Session will be cleaned up after
        // a period of non-use. 500 milliseconds was chosen because it's
        // a nice round number, and I don't know what would be a better
        // choice. Keeping this channel open will hold a file descriptor
        // which will prevent the process from exiting.
        sessionData.timeoutHandle = setTimeout(() => {
            closeSession(url);
        }, 500);
    });
}
exports.request = request;
/**
 * By default, throw for any non-2xx status code
 * @param status - status code from the HTTP response
 */
function validateStatus(status) {
    return status >= 200 && status < 300;
}
/**
 * Obtain an existing h2 session or go create a new one.
 * @param host - The hostname to which the session belongs.
 */
function _getClient(host) {
    if (!exports.sessions[host]) {
        if (DEBUG) {
            console.log(`Creating client for ${host}`);
        }
        const session = http2.connect(`https://${host}`);
        session
            .on('error', e => {
            console.error(`*ERROR*: ${e}`);
            delete exports.sessions[host];
        })
            .on('goaway', (errorCode, lastStreamId) => {
            console.error(`*GOAWAY*: ${errorCode} : ${lastStreamId}`);
            delete exports.sessions[host];
        });
        exports.sessions[host] = { session };
    }
    else {
        if (DEBUG) {
            console.log(`Used cached client for ${host}`);
        }
    }
    return exports.sessions[host];
}
async function closeSession(url) {
    const sessionData = exports.sessions[url.host];
    if (!sessionData) {
        return;
    }
    const { session } = sessionData;
    delete exports.sessions[url.host];
    if (DEBUG) {
        console.error(`Closing ${url.host}`);
    }
    session.close(() => {
        if (DEBUG) {
            console.error(`Closed ${url.host}`);
        }
    });
    setTimeout(() => {
        if (session && !session.destroyed) {
            if (DEBUG) {
                console.log(`Forcing close ${url.host}`);
            }
            if (session) {
                session.destroy();
            }
        }
    }, 1000);
}
exports.closeSession = closeSession;
//# sourceMappingURL=http2.js.map