@grpc/grpc-js
Version:
gRPC Library for Node - pure JS implementation
605 lines • 25.6 kB
JavaScript
"use strict";
/*
* Copyright 2023 gRPC authors.
*
* 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.Http2SubchannelConnector = void 0;
const http2 = require("http2");
const channelz_1 = require("./channelz");
const constants_1 = require("./constants");
const http_proxy_1 = require("./http_proxy");
const logging = require("./logging");
const resolver_1 = require("./resolver");
const subchannel_address_1 = require("./subchannel-address");
const uri_parser_1 = require("./uri-parser");
const net = require("net");
const subchannel_call_1 = require("./subchannel-call");
const call_number_1 = require("./call-number");
const TRACER_NAME = 'transport';
const FLOW_CONTROL_TRACER_NAME = 'transport_flowctrl';
const clientVersion = require('../../package.json').version;
const { HTTP2_HEADER_AUTHORITY, HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_METHOD, HTTP2_HEADER_PATH, HTTP2_HEADER_TE, HTTP2_HEADER_USER_AGENT, } = http2.constants;
const KEEPALIVE_TIMEOUT_MS = 20000;
const tooManyPingsData = Buffer.from('too_many_pings', 'ascii');
class Http2Transport {
constructor(session, subchannelAddress, options,
/**
* Name of the remote server, if it is not the same as the subchannel
* address, i.e. if connecting through an HTTP CONNECT proxy.
*/
remoteName) {
this.session = session;
this.options = options;
this.remoteName = remoteName;
/**
* Timer reference indicating when to send the next ping or when the most recent ping will be considered lost.
*/
this.keepaliveTimer = null;
/**
* Indicates that the keepalive timer ran out while there were no active
* calls, and a ping should be sent the next time a call starts.
*/
this.pendingSendKeepalivePing = false;
this.activeCalls = new Set();
this.disconnectListeners = [];
this.disconnectHandled = false;
this.channelzEnabled = true;
this.keepalivesSent = 0;
this.messagesSent = 0;
this.messagesReceived = 0;
this.lastMessageSentTimestamp = null;
this.lastMessageReceivedTimestamp = null;
/* Populate subchannelAddressString and channelzRef before doing anything
* else, because they are used in the trace methods. */
this.subchannelAddressString = (0, subchannel_address_1.subchannelAddressToString)(subchannelAddress);
if (options['grpc.enable_channelz'] === 0) {
this.channelzEnabled = false;
this.streamTracker = new channelz_1.ChannelzCallTrackerStub();
}
else {
this.streamTracker = new channelz_1.ChannelzCallTracker();
}
this.channelzRef = (0, channelz_1.registerChannelzSocket)(this.subchannelAddressString, () => this.getChannelzInfo(), this.channelzEnabled);
// Build user-agent string.
this.userAgent = [
options['grpc.primary_user_agent'],
`grpc-node-js/${clientVersion}`,
options['grpc.secondary_user_agent'],
]
.filter(e => e)
.join(' '); // remove falsey values first
if ('grpc.keepalive_time_ms' in options) {
this.keepaliveTimeMs = options['grpc.keepalive_time_ms'];
}
else {
this.keepaliveTimeMs = -1;
}
if ('grpc.keepalive_timeout_ms' in options) {
this.keepaliveTimeoutMs = options['grpc.keepalive_timeout_ms'];
}
else {
this.keepaliveTimeoutMs = KEEPALIVE_TIMEOUT_MS;
}
if ('grpc.keepalive_permit_without_calls' in options) {
this.keepaliveWithoutCalls =
options['grpc.keepalive_permit_without_calls'] === 1;
}
else {
this.keepaliveWithoutCalls = false;
}
session.once('close', () => {
this.trace('session closed');
this.handleDisconnect();
});
session.once('goaway', (errorCode, lastStreamID, opaqueData) => {
let tooManyPings = false;
/* See the last paragraph of
* https://github.com/grpc/proposal/blob/master/A8-client-side-keepalive.md#basic-keepalive */
if (errorCode === http2.constants.NGHTTP2_ENHANCE_YOUR_CALM &&
opaqueData &&
opaqueData.equals(tooManyPingsData)) {
tooManyPings = true;
}
this.trace('connection closed by GOAWAY with code ' +
errorCode +
' and data ' +
(opaqueData === null || opaqueData === void 0 ? void 0 : opaqueData.toString()));
this.reportDisconnectToOwner(tooManyPings);
});
session.once('error', error => {
this.trace('connection closed with error ' + error.message);
this.handleDisconnect();
});
session.socket.once('close', (hadError) => {
this.trace('connection closed. hadError=' + hadError);
this.handleDisconnect();
});
if (logging.isTracerEnabled(TRACER_NAME)) {
session.on('remoteSettings', (settings) => {
this.trace('new settings received' +
(this.session !== session ? ' on the old connection' : '') +
': ' +
JSON.stringify(settings));
});
session.on('localSettings', (settings) => {
this.trace('local settings acknowledged by remote' +
(this.session !== session ? ' on the old connection' : '') +
': ' +
JSON.stringify(settings));
});
}
/* Start the keepalive timer last, because this can trigger trace logs,
* which should only happen after everything else is set up. */
if (this.keepaliveWithoutCalls) {
this.maybeStartKeepalivePingTimer();
}
}
getChannelzInfo() {
var _a, _b, _c;
const sessionSocket = this.session.socket;
const remoteAddress = sessionSocket.remoteAddress
? (0, subchannel_address_1.stringToSubchannelAddress)(sessionSocket.remoteAddress, sessionSocket.remotePort)
: null;
const localAddress = sessionSocket.localAddress
? (0, subchannel_address_1.stringToSubchannelAddress)(sessionSocket.localAddress, sessionSocket.localPort)
: null;
let tlsInfo;
if (this.session.encrypted) {
const tlsSocket = sessionSocket;
const cipherInfo = tlsSocket.getCipher();
const certificate = tlsSocket.getCertificate();
const peerCertificate = tlsSocket.getPeerCertificate();
tlsInfo = {
cipherSuiteStandardName: (_a = cipherInfo.standardName) !== null && _a !== void 0 ? _a : null,
cipherSuiteOtherName: cipherInfo.standardName ? null : cipherInfo.name,
localCertificate: certificate && 'raw' in certificate ? certificate.raw : null,
remoteCertificate: peerCertificate && 'raw' in peerCertificate
? peerCertificate.raw
: null,
};
}
else {
tlsInfo = null;
}
const socketInfo = {
remoteAddress: remoteAddress,
localAddress: localAddress,
security: tlsInfo,
remoteName: this.remoteName,
streamsStarted: this.streamTracker.callsStarted,
streamsSucceeded: this.streamTracker.callsSucceeded,
streamsFailed: this.streamTracker.callsFailed,
messagesSent: this.messagesSent,
messagesReceived: this.messagesReceived,
keepAlivesSent: this.keepalivesSent,
lastLocalStreamCreatedTimestamp: this.streamTracker.lastCallStartedTimestamp,
lastRemoteStreamCreatedTimestamp: null,
lastMessageSentTimestamp: this.lastMessageSentTimestamp,
lastMessageReceivedTimestamp: this.lastMessageReceivedTimestamp,
localFlowControlWindow: (_b = this.session.state.localWindowSize) !== null && _b !== void 0 ? _b : null,
remoteFlowControlWindow: (_c = this.session.state.remoteWindowSize) !== null && _c !== void 0 ? _c : null,
};
return socketInfo;
}
trace(text) {
logging.trace(constants_1.LogVerbosity.DEBUG, TRACER_NAME, '(' +
this.channelzRef.id +
') ' +
this.subchannelAddressString +
' ' +
text);
}
keepaliveTrace(text) {
logging.trace(constants_1.LogVerbosity.DEBUG, 'keepalive', '(' +
this.channelzRef.id +
') ' +
this.subchannelAddressString +
' ' +
text);
}
flowControlTrace(text) {
logging.trace(constants_1.LogVerbosity.DEBUG, FLOW_CONTROL_TRACER_NAME, '(' +
this.channelzRef.id +
') ' +
this.subchannelAddressString +
' ' +
text);
}
internalsTrace(text) {
logging.trace(constants_1.LogVerbosity.DEBUG, 'transport_internals', '(' +
this.channelzRef.id +
') ' +
this.subchannelAddressString +
' ' +
text);
}
/**
* Indicate to the owner of this object that this transport should no longer
* be used. That happens if the connection drops, or if the server sends a
* GOAWAY.
* @param tooManyPings If true, this was triggered by a GOAWAY with data
* indicating that the session was closed becaues the client sent too many
* pings.
* @returns
*/
reportDisconnectToOwner(tooManyPings) {
if (this.disconnectHandled) {
return;
}
this.disconnectHandled = true;
this.disconnectListeners.forEach(listener => listener(tooManyPings));
}
/**
* Handle connection drops, but not GOAWAYs.
*/
handleDisconnect() {
this.clearKeepaliveTimeout();
this.reportDisconnectToOwner(false);
for (const call of this.activeCalls) {
call.onDisconnect();
}
// Wait an event loop cycle before destroying the connection
setImmediate(() => {
this.session.destroy();
});
}
addDisconnectListener(listener) {
this.disconnectListeners.push(listener);
}
canSendPing() {
return (!this.session.destroyed &&
this.keepaliveTimeMs > 0 &&
(this.keepaliveWithoutCalls || this.activeCalls.size > 0));
}
maybeSendPing() {
var _a, _b;
if (!this.canSendPing()) {
this.pendingSendKeepalivePing = true;
return;
}
if (this.keepaliveTimer) {
console.error('keepaliveTimeout is not null');
return;
}
if (this.channelzEnabled) {
this.keepalivesSent += 1;
}
this.keepaliveTrace('Sending ping with timeout ' + this.keepaliveTimeoutMs + 'ms');
this.keepaliveTimer = setTimeout(() => {
this.keepaliveTimer = null;
this.keepaliveTrace('Ping timeout passed without response');
this.handleDisconnect();
}, this.keepaliveTimeoutMs);
(_b = (_a = this.keepaliveTimer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
let pingSendError = '';
try {
const pingSentSuccessfully = this.session.ping((err, duration, payload) => {
this.clearKeepaliveTimeout();
if (err) {
this.keepaliveTrace('Ping failed with error ' + err.message);
this.handleDisconnect();
}
else {
this.keepaliveTrace('Received ping response');
this.maybeStartKeepalivePingTimer();
}
});
if (!pingSentSuccessfully) {
pingSendError = 'Ping returned false';
}
}
catch (e) {
// grpc/grpc-node#2139
pingSendError = (e instanceof Error ? e.message : '') || 'Unknown error';
}
if (pingSendError) {
this.keepaliveTrace('Ping send failed: ' + pingSendError);
this.handleDisconnect();
}
}
/**
* Starts the keepalive ping timer if appropriate. If the timer already ran
* out while there were no active requests, instead send a ping immediately.
* If the ping timer is already running or a ping is currently in flight,
* instead do nothing and wait for them to resolve.
*/
maybeStartKeepalivePingTimer() {
var _a, _b;
if (!this.canSendPing()) {
return;
}
if (this.pendingSendKeepalivePing) {
this.pendingSendKeepalivePing = false;
this.maybeSendPing();
}
else if (!this.keepaliveTimer) {
this.keepaliveTrace('Starting keepalive timer for ' + this.keepaliveTimeMs + 'ms');
this.keepaliveTimer = setTimeout(() => {
this.keepaliveTimer = null;
this.maybeSendPing();
}, this.keepaliveTimeMs);
(_b = (_a = this.keepaliveTimer).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
}
/* Otherwise, there is already either a keepalive timer or a ping pending,
* wait for those to resolve. */
}
/**
* Clears whichever keepalive timeout is currently active, if any.
*/
clearKeepaliveTimeout() {
if (this.keepaliveTimer) {
clearTimeout(this.keepaliveTimer);
this.keepaliveTimer = null;
}
}
removeActiveCall(call) {
this.activeCalls.delete(call);
if (this.activeCalls.size === 0) {
this.session.unref();
}
}
addActiveCall(call) {
this.activeCalls.add(call);
if (this.activeCalls.size === 1) {
this.session.ref();
if (!this.keepaliveWithoutCalls) {
this.maybeStartKeepalivePingTimer();
}
}
}
createCall(metadata, host, method, listener, subchannelCallStatsTracker) {
const headers = metadata.toHttp2Headers();
headers[HTTP2_HEADER_AUTHORITY] = host;
headers[HTTP2_HEADER_USER_AGENT] = this.userAgent;
headers[HTTP2_HEADER_CONTENT_TYPE] = 'application/grpc';
headers[HTTP2_HEADER_METHOD] = 'POST';
headers[HTTP2_HEADER_PATH] = method;
headers[HTTP2_HEADER_TE] = 'trailers';
let http2Stream;
/* In theory, if an error is thrown by session.request because session has
* become unusable (e.g. because it has received a goaway), this subchannel
* should soon see the corresponding close or goaway event anyway and leave
* READY. But we have seen reports that this does not happen
* (https://github.com/googleapis/nodejs-firestore/issues/1023#issuecomment-653204096)
* so for defense in depth, we just discard the session when we see an
* error here.
*/
try {
http2Stream = this.session.request(headers);
}
catch (e) {
this.handleDisconnect();
throw e;
}
this.flowControlTrace('local window size: ' +
this.session.state.localWindowSize +
' remote window size: ' +
this.session.state.remoteWindowSize);
this.internalsTrace('session.closed=' +
this.session.closed +
' session.destroyed=' +
this.session.destroyed +
' session.socket.destroyed=' +
this.session.socket.destroyed);
let eventTracker;
// eslint-disable-next-line prefer-const
let call;
if (this.channelzEnabled) {
this.streamTracker.addCallStarted();
eventTracker = {
addMessageSent: () => {
var _a;
this.messagesSent += 1;
this.lastMessageSentTimestamp = new Date();
(_a = subchannelCallStatsTracker.addMessageSent) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker);
},
addMessageReceived: () => {
var _a;
this.messagesReceived += 1;
this.lastMessageReceivedTimestamp = new Date();
(_a = subchannelCallStatsTracker.addMessageReceived) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker);
},
onCallEnd: status => {
var _a;
(_a = subchannelCallStatsTracker.onCallEnd) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker, status);
this.removeActiveCall(call);
},
onStreamEnd: success => {
var _a;
if (success) {
this.streamTracker.addCallSucceeded();
}
else {
this.streamTracker.addCallFailed();
}
(_a = subchannelCallStatsTracker.onStreamEnd) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker, success);
},
};
}
else {
eventTracker = {
addMessageSent: () => {
var _a;
(_a = subchannelCallStatsTracker.addMessageSent) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker);
},
addMessageReceived: () => {
var _a;
(_a = subchannelCallStatsTracker.addMessageReceived) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker);
},
onCallEnd: status => {
var _a;
(_a = subchannelCallStatsTracker.onCallEnd) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker, status);
this.removeActiveCall(call);
},
onStreamEnd: success => {
var _a;
(_a = subchannelCallStatsTracker.onStreamEnd) === null || _a === void 0 ? void 0 : _a.call(subchannelCallStatsTracker, success);
},
};
}
call = new subchannel_call_1.Http2SubchannelCall(http2Stream, eventTracker, listener, this, (0, call_number_1.getNextCallNumber)());
this.addActiveCall(call);
return call;
}
getChannelzRef() {
return this.channelzRef;
}
getPeerName() {
return this.subchannelAddressString;
}
getOptions() {
return this.options;
}
shutdown() {
this.session.close();
(0, channelz_1.unregisterChannelzRef)(this.channelzRef);
}
}
class Http2SubchannelConnector {
constructor(channelTarget) {
this.channelTarget = channelTarget;
this.session = null;
this.isShutdown = false;
}
trace(text) {
logging.trace(constants_1.LogVerbosity.DEBUG, TRACER_NAME, (0, uri_parser_1.uriToString)(this.channelTarget) + ' ' + text);
}
createSession(secureConnectResult, address, options) {
if (this.isShutdown) {
return Promise.reject();
}
if (secureConnectResult.socket.closed) {
return Promise.reject('Connection closed before starting HTTP/2 handshake');
}
return new Promise((resolve, reject) => {
let remoteName = null;
let realTarget = this.channelTarget;
if ('grpc.http_connect_target' in options) {
const parsedTarget = (0, uri_parser_1.parseUri)(options['grpc.http_connect_target']);
if (parsedTarget) {
realTarget = parsedTarget;
remoteName = (0, uri_parser_1.uriToString)(parsedTarget);
}
}
const scheme = secureConnectResult.secure ? 'https' : 'http';
const targetPath = (0, resolver_1.getDefaultAuthority)(realTarget);
const closeHandler = () => {
var _a;
(_a = this.session) === null || _a === void 0 ? void 0 : _a.destroy();
this.session = null;
// Leave time for error event to happen before rejecting
setImmediate(() => {
if (!reportedError) {
reportedError = true;
reject(`${errorMessage.trim()} (${new Date().toISOString()})`);
}
});
};
const errorHandler = (error) => {
var _a;
(_a = this.session) === null || _a === void 0 ? void 0 : _a.destroy();
errorMessage = error.message;
this.trace('connection failed with error ' + errorMessage);
if (!reportedError) {
reportedError = true;
reject(`${errorMessage} (${new Date().toISOString()})`);
}
};
const sessionOptions = {
createConnection: (authority, option) => {
return secureConnectResult.socket;
}
};
if (options['grpc-node.flow_control_window'] !== undefined) {
sessionOptions.settings = {
initialWindowSize: options['grpc-node.flow_control_window']
};
}
const session = http2.connect(`${scheme}://${targetPath}`, sessionOptions);
this.session = session;
let errorMessage = 'Failed to connect';
let reportedError = false;
session.unref();
session.once('remoteSettings', () => {
session.removeAllListeners();
secureConnectResult.socket.removeListener('close', closeHandler);
secureConnectResult.socket.removeListener('error', errorHandler);
resolve(new Http2Transport(session, address, options, remoteName));
this.session = null;
});
session.once('close', closeHandler);
session.once('error', errorHandler);
secureConnectResult.socket.once('close', closeHandler);
secureConnectResult.socket.once('error', errorHandler);
});
}
tcpConnect(address, options) {
return (0, http_proxy_1.getProxiedConnection)(address, options).then(proxiedSocket => {
if (proxiedSocket) {
return proxiedSocket;
}
else {
return new Promise((resolve, reject) => {
const closeCallback = () => {
reject(new Error('Socket closed'));
};
const errorCallback = (error) => {
reject(error);
};
const socket = net.connect(address, () => {
socket.removeListener('close', closeCallback);
socket.removeListener('error', errorCallback);
resolve(socket);
});
socket.once('close', closeCallback);
socket.once('error', errorCallback);
});
}
});
}
async connect(address, secureConnector, options) {
if (this.isShutdown) {
return Promise.reject();
}
let tcpConnection = null;
let secureConnectResult = null;
const addressString = (0, subchannel_address_1.subchannelAddressToString)(address);
try {
this.trace(addressString + ' Waiting for secureConnector to be ready');
await secureConnector.waitForReady();
this.trace(addressString + ' secureConnector is ready');
tcpConnection = await this.tcpConnect(address, options);
tcpConnection.setNoDelay();
this.trace(addressString + ' Established TCP connection');
secureConnectResult = await secureConnector.connect(tcpConnection);
this.trace(addressString + ' Established secure connection');
return this.createSession(secureConnectResult, address, options);
}
catch (e) {
tcpConnection === null || tcpConnection === void 0 ? void 0 : tcpConnection.destroy();
secureConnectResult === null || secureConnectResult === void 0 ? void 0 : secureConnectResult.socket.destroy();
throw e;
}
}
shutdown() {
var _a;
this.isShutdown = true;
(_a = this.session) === null || _a === void 0 ? void 0 : _a.close();
this.session = null;
}
}
exports.Http2SubchannelConnector = Http2SubchannelConnector;
//# sourceMappingURL=transport.js.map