| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563 |
- 'use strict';
- // See https://github.com/facebook/jest/issues/2549
- // eslint-disable-next-line node/prefer-global/url
- const {URL, urlToHttpOptions} = require('url');
- const http2 = require('http2');
- const {Writable} = require('stream');
- const {Agent, globalAgent} = require('./agent.js');
- const IncomingMessage = require('./incoming-message.js');
- const proxyEvents = require('./utils/proxy-events.js');
- const {
- ERR_INVALID_ARG_TYPE,
- ERR_INVALID_PROTOCOL,
- ERR_HTTP_HEADERS_SENT
- } = require('./utils/errors.js');
- const validateHeaderName = require('./utils/validate-header-name.js');
- const validateHeaderValue = require('./utils/validate-header-value.js');
- const proxySocketHandler = require('./utils/proxy-socket-handler.js');
- const {
- HTTP2_HEADER_STATUS,
- HTTP2_HEADER_METHOD,
- HTTP2_HEADER_PATH,
- HTTP2_HEADER_AUTHORITY,
- HTTP2_METHOD_CONNECT
- } = http2.constants;
- const kHeaders = Symbol('headers');
- const kOrigin = Symbol('origin');
- const kSession = Symbol('session');
- const kOptions = Symbol('options');
- const kFlushedHeaders = Symbol('flushedHeaders');
- const kJobs = Symbol('jobs');
- const kPendingAgentPromise = Symbol('pendingAgentPromise');
- class ClientRequest extends Writable {
- constructor(input, options, callback) {
- super({
- autoDestroy: false,
- emitClose: false
- });
- if (typeof input === 'string') {
- input = urlToHttpOptions(new URL(input));
- } else if (input instanceof URL) {
- input = urlToHttpOptions(input);
- } else {
- input = {...input};
- }
- if (typeof options === 'function' || options === undefined) {
- // (options, callback)
- callback = options;
- options = input;
- } else {
- // (input, options, callback)
- options = Object.assign(input, options);
- }
- if (options.h2session) {
- this[kSession] = options.h2session;
- if (this[kSession].destroyed) {
- throw new Error('The session has been closed already');
- }
- this.protocol = this[kSession].socket.encrypted ? 'https:' : 'http:';
- } else if (options.agent === false) {
- this.agent = new Agent({maxEmptySessions: 0});
- } else if (typeof options.agent === 'undefined' || options.agent === null) {
- this.agent = globalAgent;
- } else if (typeof options.agent.request === 'function') {
- this.agent = options.agent;
- } else {
- throw new ERR_INVALID_ARG_TYPE('options.agent', ['http2wrapper.Agent-like Object', 'undefined', 'false'], options.agent);
- }
- if (this.agent) {
- this.protocol = this.agent.protocol;
- }
- if (options.protocol && options.protocol !== this.protocol) {
- throw new ERR_INVALID_PROTOCOL(options.protocol, this.protocol);
- }
- if (!options.port) {
- options.port = options.defaultPort || (this.agent && this.agent.defaultPort) || 443;
- }
- options.host = options.hostname || options.host || 'localhost';
- // Unused
- delete options.hostname;
- const {timeout} = options;
- options.timeout = undefined;
- this[kHeaders] = Object.create(null);
- this[kJobs] = [];
- this[kPendingAgentPromise] = undefined;
- this.socket = null;
- this.connection = null;
- this.method = options.method || 'GET';
- if (!(this.method === 'CONNECT' && (options.path === '/' || options.path === undefined))) {
- this.path = options.path;
- }
- this.res = null;
- this.aborted = false;
- this.reusedSocket = false;
- const {headers} = options;
- if (headers) {
- // eslint-disable-next-line guard-for-in
- for (const header in headers) {
- this.setHeader(header, headers[header]);
- }
- }
- if (options.auth && !('authorization' in this[kHeaders])) {
- this[kHeaders].authorization = 'Basic ' + Buffer.from(options.auth).toString('base64');
- }
- options.session = options.tlsSession;
- options.path = options.socketPath;
- this[kOptions] = options;
- // Clients that generate HTTP/2 requests directly SHOULD use the :authority pseudo-header field instead of the Host header field.
- this[kOrigin] = new URL(`${this.protocol}//${options.servername || options.host}:${options.port}`);
- // A socket is being reused
- const reuseSocket = options._reuseSocket;
- if (reuseSocket) {
- options.createConnection = (...args) => {
- if (reuseSocket.destroyed) {
- return this.agent.createConnection(...args);
- }
- return reuseSocket;
- };
- // eslint-disable-next-line promise/prefer-await-to-then
- this.agent.getSession(this[kOrigin], this[kOptions]).catch(() => {});
- }
- if (timeout) {
- this.setTimeout(timeout);
- }
- if (callback) {
- this.once('response', callback);
- }
- this[kFlushedHeaders] = false;
- }
- get method() {
- return this[kHeaders][HTTP2_HEADER_METHOD];
- }
- set method(value) {
- if (value) {
- this[kHeaders][HTTP2_HEADER_METHOD] = value.toUpperCase();
- }
- }
- get path() {
- const header = this.method === 'CONNECT' ? HTTP2_HEADER_AUTHORITY : HTTP2_HEADER_PATH;
- return this[kHeaders][header];
- }
- set path(value) {
- if (value) {
- const header = this.method === 'CONNECT' ? HTTP2_HEADER_AUTHORITY : HTTP2_HEADER_PATH;
- this[kHeaders][header] = value;
- }
- }
- get host() {
- return this[kOrigin].hostname;
- }
- set host(_value) {
- // Do nothing as this is read only.
- }
- get _mustNotHaveABody() {
- return this.method === 'GET' || this.method === 'HEAD' || this.method === 'DELETE';
- }
- _write(chunk, encoding, callback) {
- // https://github.com/nodejs/node/blob/654df09ae0c5e17d1b52a900a545f0664d8c7627/lib/internal/http2/util.js#L148-L156
- if (this._mustNotHaveABody) {
- callback(new Error('The GET, HEAD and DELETE methods must NOT have a body'));
- /* istanbul ignore next: Node.js 12 throws directly */
- return;
- }
- this.flushHeaders();
- const callWrite = () => this._request.write(chunk, encoding, callback);
- if (this._request) {
- callWrite();
- } else {
- this[kJobs].push(callWrite);
- }
- }
- _final(callback) {
- this.flushHeaders();
- const callEnd = () => {
- // For GET, HEAD and DELETE and CONNECT
- if (this._mustNotHaveABody || this.method === 'CONNECT') {
- callback();
- return;
- }
- this._request.end(callback);
- };
- if (this._request) {
- callEnd();
- } else {
- this[kJobs].push(callEnd);
- }
- }
- abort() {
- if (this.res && this.res.complete) {
- return;
- }
- if (!this.aborted) {
- process.nextTick(() => this.emit('abort'));
- }
- this.aborted = true;
- this.destroy();
- }
- async _destroy(error, callback) {
- if (this.res) {
- this.res._dump();
- }
- if (this._request) {
- this._request.destroy();
- } else {
- process.nextTick(() => {
- this.emit('close');
- });
- }
- try {
- await this[kPendingAgentPromise];
- } catch (internalError) {
- if (this.aborted) {
- error = internalError;
- }
- }
- callback(error);
- }
- async flushHeaders() {
- if (this[kFlushedHeaders] || this.destroyed) {
- return;
- }
- this[kFlushedHeaders] = true;
- const isConnectMethod = this.method === HTTP2_METHOD_CONNECT;
- // The real magic is here
- const onStream = stream => {
- this._request = stream;
- if (this.destroyed) {
- stream.destroy();
- return;
- }
- // Forwards `timeout`, `continue`, `close` and `error` events to this instance.
- if (!isConnectMethod) {
- // TODO: Should we proxy `close` here?
- proxyEvents(stream, this, ['timeout', 'continue']);
- }
- stream.once('error', error => {
- this.destroy(error);
- });
- stream.once('aborted', () => {
- const {res} = this;
- if (res) {
- res.aborted = true;
- res.emit('aborted');
- res.destroy();
- } else {
- this.destroy(new Error('The server aborted the HTTP/2 stream'));
- }
- });
- const onResponse = (headers, flags, rawHeaders) => {
- // If we were to emit raw request stream, it would be as fast as the native approach.
- // Note that wrapping the raw stream in a Proxy instance won't improve the performance (already tested it).
- const response = new IncomingMessage(this.socket, stream.readableHighWaterMark);
- this.res = response;
- // Undocumented, but it is used by `cacheable-request`
- response.url = `${this[kOrigin].origin}${this.path}`;
- response.req = this;
- response.statusCode = headers[HTTP2_HEADER_STATUS];
- response.headers = headers;
- response.rawHeaders = rawHeaders;
- response.once('end', () => {
- response.complete = true;
- // Has no effect, just be consistent with the Node.js behavior
- response.socket = null;
- response.connection = null;
- });
- if (isConnectMethod) {
- response.upgrade = true;
- // The HTTP1 API says the socket is detached here,
- // but we can't do that so we pass the original HTTP2 request.
- if (this.emit('connect', response, stream, Buffer.alloc(0))) {
- this.emit('close');
- } else {
- // No listeners attached, destroy the original request.
- stream.destroy();
- }
- } else {
- // Forwards data
- stream.on('data', chunk => {
- if (!response._dumped && !response.push(chunk)) {
- stream.pause();
- }
- });
- stream.once('end', () => {
- if (!this.aborted) {
- response.push(null);
- }
- });
- if (!this.emit('response', response)) {
- // No listeners attached, dump the response.
- response._dump();
- }
- }
- };
- // This event tells we are ready to listen for the data.
- stream.once('response', onResponse);
- // Emits `information` event
- stream.once('headers', headers => this.emit('information', {statusCode: headers[HTTP2_HEADER_STATUS]}));
- stream.once('trailers', (trailers, flags, rawTrailers) => {
- const {res} = this;
- // https://github.com/nodejs/node/issues/41251
- if (res === null) {
- onResponse(trailers, flags, rawTrailers);
- return;
- }
- // Assigns trailers to the response object.
- res.trailers = trailers;
- res.rawTrailers = rawTrailers;
- });
- stream.once('close', () => {
- const {aborted, res} = this;
- if (res) {
- if (aborted) {
- res.aborted = true;
- res.emit('aborted');
- res.destroy();
- }
- const finish = () => {
- res.emit('close');
- this.destroy();
- this.emit('close');
- };
- if (res.readable) {
- res.once('end', finish);
- } else {
- finish();
- }
- return;
- }
- if (!this.destroyed) {
- this.destroy(new Error('The HTTP/2 stream has been early terminated'));
- this.emit('close');
- return;
- }
- this.destroy();
- this.emit('close');
- });
- this.socket = new Proxy(stream, proxySocketHandler);
- for (const job of this[kJobs]) {
- job();
- }
- this[kJobs].length = 0;
- this.emit('socket', this.socket);
- };
- if (!(HTTP2_HEADER_AUTHORITY in this[kHeaders]) && !isConnectMethod) {
- this[kHeaders][HTTP2_HEADER_AUTHORITY] = this[kOrigin].host;
- }
- // Makes a HTTP2 request
- if (this[kSession]) {
- try {
- onStream(this[kSession].request(this[kHeaders]));
- } catch (error) {
- this.destroy(error);
- }
- } else {
- this.reusedSocket = true;
- try {
- const promise = this.agent.request(this[kOrigin], this[kOptions], this[kHeaders]);
- this[kPendingAgentPromise] = promise;
- onStream(await promise);
- this[kPendingAgentPromise] = false;
- } catch (error) {
- this[kPendingAgentPromise] = false;
- this.destroy(error);
- }
- }
- }
- get connection() {
- return this.socket;
- }
- set connection(value) {
- this.socket = value;
- }
- getHeaderNames() {
- return Object.keys(this[kHeaders]);
- }
- hasHeader(name) {
- if (typeof name !== 'string') {
- throw new ERR_INVALID_ARG_TYPE('name', 'string', name);
- }
- return Boolean(this[kHeaders][name.toLowerCase()]);
- }
- getHeader(name) {
- if (typeof name !== 'string') {
- throw new ERR_INVALID_ARG_TYPE('name', 'string', name);
- }
- return this[kHeaders][name.toLowerCase()];
- }
- get headersSent() {
- return this[kFlushedHeaders];
- }
- removeHeader(name) {
- if (typeof name !== 'string') {
- throw new ERR_INVALID_ARG_TYPE('name', 'string', name);
- }
- if (this.headersSent) {
- throw new ERR_HTTP_HEADERS_SENT('remove');
- }
- delete this[kHeaders][name.toLowerCase()];
- }
- setHeader(name, value) {
- if (this.headersSent) {
- throw new ERR_HTTP_HEADERS_SENT('set');
- }
- validateHeaderName(name);
- validateHeaderValue(name, value);
- const lowercased = name.toLowerCase();
- if (lowercased === 'connection') {
- if (value.toLowerCase() === 'keep-alive') {
- return;
- }
- throw new Error(`Invalid 'connection' header: ${value}`);
- }
- if (lowercased === 'host' && this.method === 'CONNECT') {
- this[kHeaders][HTTP2_HEADER_AUTHORITY] = value;
- } else {
- this[kHeaders][lowercased] = value;
- }
- }
- setNoDelay() {
- // HTTP2 sockets cannot be malformed, do nothing.
- }
- setSocketKeepAlive() {
- // HTTP2 sockets cannot be malformed, do nothing.
- }
- setTimeout(ms, callback) {
- const applyTimeout = () => this._request.setTimeout(ms, callback);
- if (this._request) {
- applyTimeout();
- } else {
- this[kJobs].push(applyTimeout);
- }
- return this;
- }
- get maxHeadersCount() {
- if (!this.destroyed && this._request) {
- return this._request.session.localSettings.maxHeaderListSize;
- }
- return undefined;
- }
- set maxHeadersCount(_value) {
- // Updating HTTP2 settings would affect all requests, do nothing.
- }
- }
- module.exports = ClientRequest;
|