UNPKG

@connectrpc/connect-node

Version:

Connect is a family of libraries for building and consuming APIs on different languages and platforms, and [@connectrpc/connect](https://www.npmjs.com/package/@connectrpc/connect) brings type-safe APIs with Protobuf to TypeScript.

534 lines (533 loc) 21.2 kB
"use strict"; // Copyright 2021-2025 The Connect 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.Http2SessionManager = void 0; const http2 = require("http2"); const connect_1 = require("@connectrpc/connect"); const node_error_js_1 = require("./node-error.js"); /** * Manage an HTTP/2 connection and keep it alive with PING frames. * * The logic is based on "Basic Keepalive" described in * https://github.com/grpc/proposal/blob/0ba0c1905050525f9b0aee46f3f23c8e1e515489/A8-client-side-keepalive.md#basic-keepalive * as well as the client channel arguments described in * https://github.com/grpc/grpc/blob/8e137e524a1b1da7bbf4603662876d5719563b57/doc/keepalive.md * * Usually, the managers tracks exactly one connection, but if a connection * receives a GOAWAY frame with NO_ERROR, the connection is maintained until * all streams have finished, and new requests will open a new connection. */ class Http2SessionManager { /** * The current state of the connection: * * - "closed" * The connection is closed, or no connection has been opened yet. * - connecting * Currently establishing a connection. * * - "open" * A connection is open and has open streams. PING frames are sent every * pingIntervalMs, unless a stream received data. * If a PING frame is not responded to within pingTimeoutMs, the connection * and all open streams close. * * - "idle" * A connection is open, but it does not have any open streams. * If pingIdleConnection is enabled, PING frames are used to keep the * connection alive, similar to an "open" connection. * If a connection is idle for longer than idleConnectionTimeoutMs, it closes. * If a request is made on an idle connection that has not been used for * longer than pingIntervalMs, the connection is verified. * * - "verifying" * Verifying a connection after a long period of inactivity before issuing a * request. A PING frame is sent, and if it times out within pingTimeoutMs, a * new connection is opened. * * - "error" * The connection is closed because of a transient error. A connection * may have failed to reach the host, or the connection may have died, * or it may have been aborted. */ state() { if (this.s.t == "ready") { if (this.verifying !== undefined) { return "verifying"; } return this.s.streamCount() > 0 ? "open" : "idle"; } return this.s.t; } /** * Returns the error object if the connection is in the "error" state, * `undefined` otherwise. */ error() { if (this.s.t == "error") { return this.s.reason; } return undefined; } constructor(url, pingOptions, http2SessionOptions) { var _a, _b, _c, _d; this.s = closed(); this.shuttingDown = []; this.authority = new URL(url).origin; this.http2SessionOptions = http2SessionOptions; this.options = { pingIntervalMs: (_a = pingOptions === null || pingOptions === void 0 ? void 0 : pingOptions.pingIntervalMs) !== null && _a !== void 0 ? _a : Number.POSITIVE_INFINITY, pingTimeoutMs: (_b = pingOptions === null || pingOptions === void 0 ? void 0 : pingOptions.pingTimeoutMs) !== null && _b !== void 0 ? _b : 1000 * 15, pingIdleConnection: (_c = pingOptions === null || pingOptions === void 0 ? void 0 : pingOptions.pingIdleConnection) !== null && _c !== void 0 ? _c : false, idleConnectionTimeoutMs: (_d = pingOptions === null || pingOptions === void 0 ? void 0 : pingOptions.idleConnectionTimeoutMs) !== null && _d !== void 0 ? _d : 1000 * 60 * 15, }; } /** * Open a connection if none exists, verify an existing connection if * necessary. */ async connect() { try { const ready = await this.gotoReady(); return ready.streamCount() > 0 ? "open" : "idle"; } catch (e) { return "error"; } } /** * Issue a request. * * This method automatically opens a connection if none exists, and verifies * an existing connection if necessary. It calls http2.ClientHttp2Session.request(), * and keeps track of all open http2.ClientHttp2Stream. * * Clients must call notifyResponseByteRead() whenever they successfully read * data from the http2.ClientHttp2Stream. */ async request(method, path, headers, options) { // Request sometimes fails with goaway/destroyed // errors, we use a loop to retry. // // This is not expected to happen often, but it is possible that a // connection is closed while we are trying to open a stream. // // Ref: https://github.com/nodejs/help/issues/2105 for (;;) { const ready = await this.gotoReady(); try { const stream = ready.conn.request(Object.assign(Object.assign({}, headers), { ":method": method, ":path": path }), options); ready.registerRequest(stream); return stream; } catch (e) { // Check to see if the connection is closed or destroyed // and if so, we try again. if (ready.conn.closed || ready.conn.destroyed) { continue; } throw e; } } } /** * Notify the manager of a successful read from a http2.ClientHttp2Stream. * * Clients must call this function whenever they successfully read data from * a http2.ClientHttp2Stream obtained from request(). This informs the * keep-alive logic that the connection is alive, and prevents it from sending * unnecessary PING frames. */ notifyResponseByteRead(stream) { if (this.s.t == "ready") { this.s.responseByteRead(stream); } for (const s of this.shuttingDown) { s.responseByteRead(stream); } } /** * If there is an open connection, close it. This also closes any open streams. */ abort(reason) { var _a, _b, _c; const err = reason !== null && reason !== void 0 ? reason : new connect_1.ConnectError("connection aborted", connect_1.Code.Canceled); (_b = (_a = this.s).abort) === null || _b === void 0 ? void 0 : _b.call(_a, err); for (const s of this.shuttingDown) { (_c = s.abort) === null || _c === void 0 ? void 0 : _c.call(s, err); } this.setState(closedOrError(err)); } async gotoReady() { if (this.s.t == "ready") { if (this.s.isShuttingDown() || this.s.conn.closed || this.s.conn.destroyed) { this.setState(connect(this.authority, this.http2SessionOptions)); } else if (this.s.requiresVerify()) { await this.verify(this.s); } } else if (this.s.t == "closed" || this.s.t == "error") { this.setState(connect(this.authority, this.http2SessionOptions)); } while (this.s.t !== "ready") { if (this.s.t === "error") { throw this.s.reason; } if (this.s.t === "connecting") { await this.s.conn; } } return this.s; } setState(state) { var _a, _b; (_b = (_a = this.s).onExitState) === null || _b === void 0 ? void 0 : _b.call(_a); if (this.s.t == "ready" && this.s.isShuttingDown()) { // Maintain connections that have been asked to shut down. const sd = this.s; this.shuttingDown.push(sd); sd.onClose = sd.onError = () => { const i = this.shuttingDown.indexOf(sd); if (i !== -1) { this.shuttingDown.splice(i, 1); } }; } switch (state.t) { case "connecting": state.conn.then((value) => { this.setState(ready(value, this.options)); }, (reason) => { this.setState(closedOrError(reason)); }); break; case "ready": state.onClose = () => this.setState(closed()); state.onError = (err) => this.setState(closedOrError(err)); break; case "closed": break; case "error": break; } this.s = state; } verify(stateReady) { if (this.verifying !== undefined) { return this.verifying; } this.verifying = stateReady .verify() .then((success) => { if (success) { return; } // verify() has destroyed the old connection this.setState(connect(this.authority, this.http2SessionOptions)); }, (reason) => { this.setState(closedOrError(reason)); }) .finally(() => { this.verifying = undefined; }); return this.verifying; } } exports.Http2SessionManager = Http2SessionManager; function closed() { return { t: "closed", }; } function error(reason) { return { t: "error", reason, }; } function closedOrError(reason) { const isCancel = reason instanceof connect_1.ConnectError && connect_1.ConnectError.from(reason).code == connect_1.Code.Canceled; return isCancel ? closed() : error(reason); } function connect(authority, http2SessionOptions) { let resolve; let reject; const conn = new Promise((res, rej) => { resolve = res; reject = rej; }); const newConn = http2.connect(authority, http2SessionOptions); newConn.on("connect", onConnect); newConn.on("error", onError); function onConnect() { resolve === null || resolve === void 0 ? void 0 : resolve(newConn); cleanup(); } function onError(err) { reject === null || reject === void 0 ? void 0 : reject((0, node_error_js_1.connectErrorFromNodeReason)(err)); cleanup(); } function cleanup() { newConn.off("connect", onConnect); newConn.off("error", onError); } return { t: "connecting", conn, abort(reason) { if (!newConn.destroyed) { newConn.destroy(undefined, http2.constants.NGHTTP2_CANCEL); } // According to the documentation, destroy() should immediately terminate // the session and the socket, but we still receive a "connect" event. // We must not resolve a broken connection, so we reject it manually here. reject === null || reject === void 0 ? void 0 : reject(reason); }, onExitState() { cleanup(); }, }; } function ready(conn, options) { // Users have reported an error "The session has been destroyed" raised // from H2SessionManager.request(), see https://github.com/connectrpc/connect-es/issues/683 // This assertion will show whether the session already died in the // "connecting" state. assertSessionOpen(conn); // Do not block Node.js from exiting on an idle connection. // Note that we ref() again for the first stream to open, and unref() again // for the last stream to close. conn.unref(); // the last time we were sure that the connection is alive, via a PING // response, or via received response bytes let lastAliveAt = Date.now(); // how many streams are currently open on this session let streamCount = 0; // timer for the keep-alive interval let pingIntervalId; // timer for waiting for a PING response let pingTimeoutId; // keep track of GOAWAY - gracefully shut down open streams / wait for connection to error let receivedGoAway = false; // keep track of GOAWAY with ENHANCE_YOUR_CALM and with debug data too_many_pings let receivedGoAwayEnhanceYourCalmTooManyPings = false; // timer for closing connections without open streams, must be initialized let idleTimeoutId; resetIdleTimeout(); const state = { t: "ready", conn, streamCount() { return streamCount; }, requiresVerify() { const elapsedMs = Date.now() - lastAliveAt; return elapsedMs > options.pingIntervalMs; }, isShuttingDown() { return receivedGoAway; }, onClose: undefined, onError: undefined, registerRequest(stream) { streamCount++; if (streamCount == 1) { conn.ref(); resetPingInterval(); // reset to ping with the appropriate interval for "open" stopIdleTimeout(); } stream.once("response", () => { lastAliveAt = Date.now(); resetPingInterval(); }); stream.once("close", () => { streamCount--; if (streamCount == 0) { conn.unref(); resetPingInterval(); // reset to ping with the appropriate interval for "idle" resetIdleTimeout(); } }); }, responseByteRead(stream) { if (stream.session !== conn) { return; } if (conn.closed || conn.destroyed) { return; } if (streamCount <= 0) { return; } lastAliveAt = Date.now(); resetPingInterval(); }, verify() { conn.ref(); return new Promise((resolve) => { commonPing(() => { if (streamCount == 0) conn.unref(); resolve(true); }); conn.once("error", () => resolve(false)); }); }, abort(reason) { if (!conn.destroyed) { conn.once("error", () => { // conn.destroy() may raise an error after onExitState() was called // and our error listeners are removed. // We attach this one to swallow uncaught exceptions. }); conn.destroy(reason, http2.constants.NGHTTP2_CANCEL); } }, onExitState() { if (state.isShuttingDown()) { // Per the interface, this method is called when the manager is leaving // the state. We maintain this connection in the session manager until // all streams have finished, so we do not detach event listeners here. return; } cleanup(); this.onError = undefined; this.onClose = undefined; }, }; // start or restart the ping interval function resetPingInterval() { stopPingInterval(); if (streamCount > 0 || options.pingIdleConnection) { pingIntervalId = safeSetTimeout(onPingInterval, options.pingIntervalMs); } } function stopPingInterval() { clearTimeout(pingIntervalId); clearTimeout(pingTimeoutId); } function onPingInterval() { commonPing(resetPingInterval); } function commonPing(onSuccess) { clearTimeout(pingTimeoutId); pingTimeoutId = safeSetTimeout(() => { conn.destroy(new connect_1.ConnectError("PING timed out", connect_1.Code.Unavailable), http2.constants.NGHTTP2_CANCEL); }, options.pingTimeoutMs); conn.ping((err, duration) => { clearTimeout(pingTimeoutId); if (err !== null) { // We will receive an ERR_HTTP2_PING_CANCEL here if we destroy the // connection with a pending ping. // We might also see other errors, but they should be picked up by the // "error" event listener. return; } if (duration > options.pingTimeoutMs) { // setTimeout is not precise, and HTTP/2 pings take less than 1ms in // tests. conn.destroy(new connect_1.ConnectError("PING timed out", connect_1.Code.Unavailable), http2.constants.NGHTTP2_CANCEL); return; } lastAliveAt = Date.now(); onSuccess(); }); } function stopIdleTimeout() { clearTimeout(idleTimeoutId); } function resetIdleTimeout() { idleTimeoutId = safeSetTimeout(onIdleTimeout, options.idleConnectionTimeoutMs); } function onIdleTimeout() { conn.close(); onClose(); // trigger a state change right away, so we are not open to races } function onGoaway(errorCode, lastStreamID, opaqueData) { receivedGoAway = true; const tooManyPingsAscii = Buffer.from("too_many_pings", "ascii"); if (errorCode === http2.constants.NGHTTP2_ENHANCE_YOUR_CALM && opaqueData != null && opaqueData.equals(tooManyPingsAscii)) { // double pingIntervalMs, following the last paragraph of https://github.com/grpc/proposal/blob/0ba0c1905050525f9b0aee46f3f23c8e1e515489/A8-client-side-keepalive.md#basic-keepalive options.pingIntervalMs = options.pingIntervalMs * 2; receivedGoAwayEnhanceYourCalmTooManyPings = true; } if (errorCode === http2.constants.NGHTTP2_NO_ERROR && streamCount == 0) { // Node.js v16 closes the connection on its own when it receives a GOAWAY // frame and there are no open streams (emitting a "close" event and // destroying the session), but later versions do not. // Calling close() ourselves is ineffective here - it appears that the // method is already being called, see https://github.com/nodejs/node/blob/198affc63973805ce5102d246f6b7822be57f5fc/lib/internal/http2/core.js#L681 conn.destroy(new connect_1.ConnectError("received GOAWAY without any open streams", connect_1.Code.Canceled), http2.constants.NGHTTP2_NO_ERROR); } } function onClose() { var _a; cleanup(); (_a = state.onClose) === null || _a === void 0 ? void 0 : _a.call(state); } function onError(err) { var _a, _b; cleanup(); if (receivedGoAwayEnhanceYourCalmTooManyPings) { // We cannot prevent node from destroying session and streams with its own // error that does not carry debug data, but at least we can wrap the error // we surface on the manager. const ce = new connect_1.ConnectError(`http/2 connection closed with error code ENHANCE_YOUR_CALM (0x${http2.constants.NGHTTP2_ENHANCE_YOUR_CALM.toString(16)}), too_many_pings, doubled the interval`, connect_1.Code.ResourceExhausted); (_a = state.onError) === null || _a === void 0 ? void 0 : _a.call(state, ce); } else { (_b = state.onError) === null || _b === void 0 ? void 0 : _b.call(state, (0, node_error_js_1.connectErrorFromNodeReason)(err)); } } function cleanup() { stopPingInterval(); stopIdleTimeout(); conn.off("error", onError); conn.off("close", onClose); conn.off("goaway", onGoaway); } conn.on("error", onError); conn.on("close", onClose); conn.on("goaway", onGoaway); return state; } /** * setTimeout(), but simply ignores values larger than the maximum supported * value (signed 32-bit integer) instead of calling the callback right away, * and does not block Node.js from exiting. */ function safeSetTimeout(callback, ms) { if (ms > 0x7fffffff) { return; } return setTimeout(callback, ms).unref(); } function assertSessionOpen(conn) { if (conn.connecting) { throw new connect_1.ConnectError("expected open session, but it is connecting", connect_1.Code.Internal); } if (conn.destroyed) { throw new connect_1.ConnectError("expected open session, but it is destroyed", connect_1.Code.Internal); } if (conn.closed) { throw new connect_1.ConnectError("expected open session, but it is closed", connect_1.Code.Internal); } }