my-idlers/node_modules/http2-wrapper/source/client-request.js

446 lines
11 KiB
JavaScript
Raw Normal View History

'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;