123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- 'use strict';
- const http2 = require('http2');
- const {Writable} = require('stream');
- const {Agent, globalAgent} = require('./agent');
- const IncomingMessage = require('./incoming-message');
- const urlToOptions = require('./utils/url-to-options');
- const proxyEvents = require('./utils/proxy-events');
- const isRequestPseudoHeader = require('./utils/is-request-pseudo-header');
- const {
- ERR_INVALID_ARG_TYPE,
- ERR_INVALID_PROTOCOL,
- ERR_HTTP_HEADERS_SENT,
- ERR_INVALID_HTTP_TOKEN,
- ERR_HTTP_INVALID_HEADER_VALUE,
- ERR_INVALID_CHAR
- } = require('./utils/errors');
-
- const {
- HTTP2_HEADER_STATUS,
- HTTP2_HEADER_METHOD,
- HTTP2_HEADER_PATH,
- 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 isValidHttpToken = /^[\^`\-\w!#$%&*+.|~]+$/;
- const isInvalidHeaderValue = /[^\t\u0020-\u007E\u0080-\u00FF]/;
-
- class ClientRequest extends Writable {
- constructor(input, options, callback) {
- super({
- autoDestroy: false
- });
-
- const hasInput = typeof input === 'string' || input instanceof URL;
- if (hasInput) {
- input = urlToOptions(input instanceof URL ? input : new URL(input));
- }
-
- if (typeof options === 'function' || options === undefined) {
- // (options, callback)
- callback = options;
- options = hasInput ? input : {...input};
- } else {
- // (input, options, callback)
- options = {...input, ...options};
- }
-
- if (options.h2session) {
- this[kSession] = options.h2session;
- } else if (options.agent === false) {
- this.agent = new Agent({maxFreeSessions: 0});
- } else if (typeof options.agent === 'undefined' || options.agent === null) {
- if (typeof options.createConnection === 'function') {
- // This is a workaround - we don't have to create the session on our own.
- this.agent = new Agent({maxFreeSessions: 0});
- this.agent.createConnection = options.createConnection;
- } else {
- this.agent = globalAgent;
- }
- } else if (typeof options.agent.request === 'function') {
- this.agent = options.agent;
- } else {
- throw new ERR_INVALID_ARG_TYPE('options.agent', ['Agent-like Object', 'undefined', 'false'], options.agent);
- }
-
- if (options.protocol && options.protocol !== 'https:') {
- throw new ERR_INVALID_PROTOCOL(options.protocol, 'https:');
- }
-
- const port = options.port || options.defaultPort || (this.agent && this.agent.defaultPort) || 443;
- const host = options.hostname || options.host || 'localhost';
-
- // Don't enforce the origin via options. It may be changed in an Agent.
- delete options.hostname;
- delete options.host;
- delete options.port;
-
- const {timeout} = options;
- options.timeout = undefined;
-
- this[kHeaders] = Object.create(null);
- this[kJobs] = [];
-
- this.socket = null;
- this.connection = null;
-
- this.method = options.method || 'GET';
- this.path = options.path;
-
- this.res = null;
- this.aborted = false;
- this.reusedSocket = false;
-
- if (options.headers) {
- for (const [header, value] of Object.entries(options.headers)) {
- this.setHeader(header, value);
- }
- }
-
- 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.
- if (port === 443) {
- this[kOrigin] = `https://${host}`;
-
- if (!(':authority' in this[kHeaders])) {
- this[kHeaders][':authority'] = host;
- }
- } else {
- this[kOrigin] = `https://${host}:${port}`;
-
- if (!(':authority' in this[kHeaders])) {
- this[kHeaders][':authority'] = `${host}:${port}`;
- }
- }
-
- 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() {
- return this[kHeaders][HTTP2_HEADER_PATH];
- }
-
- set path(value) {
- if (value) {
- this[kHeaders][HTTP2_HEADER_PATH] = value;
- }
- }
-
- 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) {
- if (this.destroyed) {
- return;
- }
-
- this.flushHeaders();
-
- const callEnd = () => {
- // For GET, HEAD and DELETE
- if (this._mustNotHaveABody) {
- 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();
- }
-
- _destroy(error, callback) {
- if (this.res) {
- this.res._dump();
- }
-
- if (this._request) {
- this._request.destroy();
- }
-
- 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) {
- proxyEvents(stream, this, ['timeout', 'continue', 'close', 'error']);
- }
-
- // Wait for the `finish` event. We don't want to emit the `response` event
- // before `request.end()` is called.
- const waitForEnd = fn => {
- return (...args) => {
- if (!this.writable && !this.destroyed) {
- fn(...args);
- } else {
- this.once('finish', () => {
- fn(...args);
- });
- }
- };
- };
-
- // This event tells we are ready to listen for the data.
- stream.once('response', waitForEnd((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;
-
- response.req = this;
- response.statusCode = headers[HTTP2_HEADER_STATUS];
- response.headers = headers;
- response.rawHeaders = rawHeaders;
-
- response.once('end', () => {
- if (this.aborted) {
- response.aborted = true;
- response.emit('aborted');
- } else {
- 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', () => {
- response.push(null);
- });
-
- if (!this.emit('response', response)) {
- // No listeners attached, dump the response.
- response._dump();
- }
- }
- }));
-
- // Emits `information` event
- stream.once('headers', waitForEnd(
- headers => this.emit('information', {statusCode: headers[HTTP2_HEADER_STATUS]})
- ));
-
- stream.once('trailers', waitForEnd((trailers, flags, rawTrailers) => {
- const {res} = this;
-
- // Assigns trailers to the response object.
- res.trailers = trailers;
- res.rawTrailers = rawTrailers;
- }));
-
- const {socket} = stream.session;
- this.socket = socket;
- this.connection = socket;
-
- for (const job of this[kJobs]) {
- job();
- }
-
- this.emit('socket', this.socket);
- };
-
- // Makes a HTTP2 request
- if (this[kSession]) {
- try {
- onStream(this[kSession].request(this[kHeaders]));
- } catch (error) {
- this.emit('error', error);
- }
- } else {
- this.reusedSocket = true;
-
- try {
- onStream(await this.agent.request(this[kOrigin], this[kOptions], this[kHeaders]));
- } catch (error) {
- this.emit('error', error);
- }
- }
- }
-
- 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');
- }
-
- if (typeof name !== 'string' || (!isValidHttpToken.test(name) && !isRequestPseudoHeader(name))) {
- throw new ERR_INVALID_HTTP_TOKEN('Header name', name);
- }
-
- if (typeof value === 'undefined') {
- throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name);
- }
-
- if (isInvalidHeaderValue.test(value)) {
- throw new ERR_INVALID_CHAR('header content', name);
- }
-
- this[kHeaders][name.toLowerCase()] = 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;
|