From 26c5fe4fd521ee7a304a9a0f48d6e2e50b90dc5d Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Fri, 12 Sep 2025 15:43:04 -0700 Subject: [PATCH 01/17] grpc-js(-xds): Bump to 1.14.0 and update README --- packages/grpc-js-xds/README.md | 4 +++- packages/grpc-js-xds/package.json | 4 ++-- packages/grpc-js/package.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/grpc-js-xds/README.md b/packages/grpc-js-xds/README.md index bab5a588f..f97f66032 100644 --- a/packages/grpc-js-xds/README.md +++ b/packages/grpc-js-xds/README.md @@ -31,9 +31,11 @@ const client = new MyServiceClient('xds:///example.com:123'); - [Outlier Detection](https://github.com/grpc/proposal/blob/master/A50-xds-outlier-detection.md) - [xDS Retry Support](https://github.com/grpc/proposal/blob/master/A44-xds-retry.md) - [xDS Aggregate and Logical DNS Clusters](https://github.com/grpc/proposal/blob/master/A37-xds-aggregate-and-logical-dns-clusters.md) - - [xDS Federation](https://github.com/grpc/proposal/blob/master/A47-xds-federation.md) (Currently experimental, enabled by environment variable `GRPC_EXPERIMENTAL_XDS_FEDERATION`) + - [xDS Federation](https://github.com/grpc/proposal/blob/master/A47-xds-federation.md) - [xDS Custom Load Balancer Configuration](https://github.com/grpc/proposal/blob/master/A52-xds-custom-lb-policies.md) (Custom load balancer registration not currently supported) - [xDS Ring Hash LB Policy](https://github.com/grpc/proposal/blob/master/A42-xds-ring-hash-lb-policy.md) - [`pick_first` via xDS](https://github.com/grpc/proposal/blob/master/A62-pick-first.md#pick_first-via-xds-1) (Currently experimental, enabled by environment variable `GRPC_EXPERIMENTAL_PICKFIRST_LB_CONFIG`) - [xDS-Enabled Servers](https://github.com/grpc/proposal/blob/master/A36-xds-for-servers.md) - [xDS-Based Security for gRPC Clients and Servers](https://github.com/grpc/proposal/blob/master/A29-xds-tls-security.md) + - [xDS RBAC Support](https://github.com/grpc/proposal/blob/master/A41-xds-rbac.md) + - [`weighted_round_robin` LB policy](https://github.com/grpc/proposal/blob/master/A58-client-side-weighted-round-robin-lb-policy.md) (Inclusion in xDS registry is currently experimental, enabled by environment variable `GRPC_EXPERIMENTAL_XDS_WRR_LB`) diff --git a/packages/grpc-js-xds/package.json b/packages/grpc-js-xds/package.json index 8d937db59..15b8d65a4 100644 --- a/packages/grpc-js-xds/package.json +++ b/packages/grpc-js-xds/package.json @@ -1,6 +1,6 @@ { "name": "@grpc/grpc-js-xds", - "version": "1.13.0", + "version": "1.14.0", "description": "Plugin for @grpc/grpc-js. Adds the xds:// URL scheme and associated features.", "main": "build/src/index.js", "scripts": { @@ -55,7 +55,7 @@ "xxhash-wasm": "^1.0.2" }, "peerDependencies": { - "@grpc/grpc-js": "~1.13.0" + "@grpc/grpc-js": "~1.14.0" }, "engines": { "node": ">=10.10.0" diff --git a/packages/grpc-js/package.json b/packages/grpc-js/package.json index 4bb31e9a4..bbca7caad 100644 --- a/packages/grpc-js/package.json +++ b/packages/grpc-js/package.json @@ -1,6 +1,6 @@ { "name": "@grpc/grpc-js", - "version": "1.13.4", + "version": "1.14.0", "description": "gRPC Library for Node - pure JS implementation", "homepage": "https://grpc.io/", "repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js", From dcb2182c2307342730ef21943d4935d8967a5dfb Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Tue, 4 Nov 2025 11:23:06 -0800 Subject: [PATCH 02/17] grpc-js: Re-add a couple of accidentally removed HTTP/2 session settings --- packages/grpc-js/package.json | 2 +- packages/grpc-js/src/transport.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/grpc-js/package.json b/packages/grpc-js/package.json index bbca7caad..f5f717089 100644 --- a/packages/grpc-js/package.json +++ b/packages/grpc-js/package.json @@ -1,6 +1,6 @@ { "name": "@grpc/grpc-js", - "version": "1.14.0", + "version": "1.14.1", "description": "gRPC Library for Node - pure JS implementation", "homepage": "https://grpc.io/", "repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js", diff --git a/packages/grpc-js/src/transport.ts b/packages/grpc-js/src/transport.ts index 6fea1198c..a1cca59e5 100644 --- a/packages/grpc-js/src/transport.ts +++ b/packages/grpc-js/src/transport.ts @@ -720,7 +720,13 @@ export class Http2SubchannelConnector implements SubchannelConnector { initialWindowSize: options['grpc-node.flow_control_window'] ?? http2.getDefaultSettings?.()?.initialWindowSize ?? 65535, - } + }, + maxSendHeaderBlockLength: Number.MAX_SAFE_INTEGER, + /* By default, set a very large max session memory limit, to effectively + * disable enforcement of the limit. Some testing indicates that Node's + * behavior degrades badly when this limit is reached, so we solve that + * by disabling the check entirely. */ + maxSessionMemory: options['grpc-node.max_session_memory'] ?? Number.MAX_SAFE_INTEGER }; const session = http2.connect(`${scheme}://${targetPath}`, sessionOptions); // Prepare window size configuration for remoteSettings handler From 2f090c62a66e36c756350e9a942f965dca1ae276 Mon Sep 17 00:00:00 2001 From: Mattias Wiberg Date: Fri, 24 Oct 2025 16:06:54 +0200 Subject: [PATCH 03/17] grpc-js: Fix server keep alive timeout not properly destroying connections --- packages/grpc-js/src/server.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/grpc-js/src/server.ts b/packages/grpc-js/src/server.ts index 6e68c695a..73d84b787 100644 --- a/packages/grpc-js/src/server.ts +++ b/packages/grpc-js/src/server.ts @@ -1609,7 +1609,7 @@ export class Server { if (err) { this.keepaliveTrace('Ping failed with error: ' + err.message); sessionClosedByServer = true; - session.close(); + session.destroy(); } else { this.keepaliveTrace('Received ping response'); maybeStartKeepalivePingTimer(); @@ -1631,7 +1631,7 @@ export class Server { 'Connection dropped due to ping send error: ' + pingSendError ); sessionClosedByServer = true; - session.close(); + session.destroy(); return; } @@ -1640,7 +1640,7 @@ export class Server { this.keepaliveTrace('Ping timeout passed without response'); this.trace('Connection dropped by keepalive timeout'); sessionClosedByServer = true; - session.close(); + session.destroy(); }, this.keepaliveTimeoutMs); keepaliveTimer.unref?.(); }; @@ -1803,7 +1803,7 @@ export class Server { duration ); sessionClosedByServer = true; - session.close(); + session.destroy(); } else { this.keepaliveTrace('Received ping response'); maybeStartKeepalivePingTimer(); @@ -1826,7 +1826,7 @@ export class Server { 'Connection dropped due to ping send error: ' + pingSendError ); sessionClosedByServer = true; - session.close(); + session.destroy(); return; } @@ -1840,7 +1840,7 @@ export class Server { 'Connection dropped by keepalive timeout from ' + clientAddress ); sessionClosedByServer = true; - session.close(); + session.destroy(); }, this.keepaliveTimeoutMs); keepaliveTimeout.unref?.(); }; From 235474ef4ad8e6a5ab678001d461e83c4518fe06 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Mon, 1 Dec 2025 12:03:35 -0500 Subject: [PATCH 04/17] grpc-js: Bump to 1.14.2 --- packages/grpc-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grpc-js/package.json b/packages/grpc-js/package.json index f5f717089..b157dc37c 100644 --- a/packages/grpc-js/package.json +++ b/packages/grpc-js/package.json @@ -1,6 +1,6 @@ { "name": "@grpc/grpc-js", - "version": "1.14.1", + "version": "1.14.2", "description": "gRPC Library for Node - pure JS implementation", "homepage": "https://grpc.io/", "repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js", From 4e26a27f6cf728237672506e69d594dc137dcbc4 Mon Sep 17 00:00:00 2001 From: Serkan Erip Date: Mon, 8 Dec 2025 17:02:53 +0300 Subject: [PATCH 05/17] half close right after write --- packages/grpc-js/src/retrying-call.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/grpc-js/src/retrying-call.ts b/packages/grpc-js/src/retrying-call.ts index 1d49ad337..cb4f7b3f8 100644 --- a/packages/grpc-js/src/retrying-call.ts +++ b/packages/grpc-js/src/retrying-call.ts @@ -211,6 +211,7 @@ export class RetryingCall implements Call, DeadlineInfoProvider { private nextRetryBackoffSec = 0; private startTime: Date; private maxAttempts: number; + private halfClosed: boolean = false; constructor( private readonly channel: InternalChannel, private readonly callConfig: CallConfig, @@ -789,8 +790,11 @@ export class RetryingCall implements Call, DeadlineInfoProvider { ); break; case 'HALF_CLOSE': - childCall.nextMessageToSend += 1; - childCall.call.halfClose(); + if (this.halfClosed !== true) { + childCall.nextMessageToSend += 1; + childCall.call.halfClose(); + this.halfClosed = true; + } break; case 'FREED': // Should not be possible @@ -868,12 +872,13 @@ export class RetryingCall implements Call, DeadlineInfoProvider { allocated: false, }); for (const call of this.underlyingCalls) { - if ( - call?.state === 'ACTIVE' && - call.nextMessageToSend === halfCloseIndex - ) { - call.nextMessageToSend += 1; - call.call.halfClose(); + if (call?.state === 'ACTIVE' && this.halfClosed !== true) { + // || call.nextMessageToSend === halfCloseIndex - 1 added to not wait for write callback + if (call.nextMessageToSend === halfCloseIndex || call.nextMessageToSend === halfCloseIndex - 1) { + call.nextMessageToSend += 1; + call.call.halfClose(); + this.halfClosed = true; + } } } } From 37f28177c045471a673f72aae55e62a89f807deb Mon Sep 17 00:00:00 2001 From: Serkan Erip Date: Mon, 8 Dec 2025 20:50:49 +0300 Subject: [PATCH 06/17] revert changes --- packages/grpc-js/src/retrying-call.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/grpc-js/src/retrying-call.ts b/packages/grpc-js/src/retrying-call.ts index cb4f7b3f8..5a7957fd2 100644 --- a/packages/grpc-js/src/retrying-call.ts +++ b/packages/grpc-js/src/retrying-call.ts @@ -211,7 +211,6 @@ export class RetryingCall implements Call, DeadlineInfoProvider { private nextRetryBackoffSec = 0; private startTime: Date; private maxAttempts: number; - private halfClosed: boolean = false; constructor( private readonly channel: InternalChannel, private readonly callConfig: CallConfig, @@ -790,11 +789,8 @@ export class RetryingCall implements Call, DeadlineInfoProvider { ); break; case 'HALF_CLOSE': - if (this.halfClosed !== true) { - childCall.nextMessageToSend += 1; - childCall.call.halfClose(); - this.halfClosed = true; - } + childCall.nextMessageToSend += 1; + childCall.call.halfClose(); break; case 'FREED': // Should not be possible @@ -872,13 +868,12 @@ export class RetryingCall implements Call, DeadlineInfoProvider { allocated: false, }); for (const call of this.underlyingCalls) { - if (call?.state === 'ACTIVE' && this.halfClosed !== true) { - // || call.nextMessageToSend === halfCloseIndex - 1 added to not wait for write callback - if (call.nextMessageToSend === halfCloseIndex || call.nextMessageToSend === halfCloseIndex - 1) { - call.nextMessageToSend += 1; - call.call.halfClose(); - this.halfClosed = true; - } + if ( + call?.state === 'ACTIVE' && + call.nextMessageToSend === halfCloseIndex + ) { + call.nextMessageToSend += 1; + call.call.halfClose(); } } } @@ -900,4 +895,4 @@ export class RetryingCall implements Call, DeadlineInfoProvider { return null; } } -} +} \ No newline at end of file From f6895cbf408af9042569914a049e3ff0054b1299 Mon Sep 17 00:00:00 2001 From: Serkan Erip Date: Tue, 9 Dec 2025 22:36:25 +0300 Subject: [PATCH 07/17] Send halfClose immediately after messages to prevent late halfClose issues with Envoy --- packages/grpc-js/src/retrying-call.ts | 56 +++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/grpc-js/src/retrying-call.ts b/packages/grpc-js/src/retrying-call.ts index 5a7957fd2..cbaec082c 100644 --- a/packages/grpc-js/src/retrying-call.ts +++ b/packages/grpc-js/src/retrying-call.ts @@ -123,6 +123,15 @@ interface UnderlyingCall { state: UnderlyingCallState; call: LoadBalancingCall; nextMessageToSend: number; + /** + * Tracks the highest message index that has been sent to the underlying call. + * This is different from nextMessageToSend which tracks completion/acknowledgment. + */ + highestSentMessageIndex: number; + /** + * Tracks whether halfClose has been sent to this child call. + */ + halfCloseSent: boolean; startTime: Date; } @@ -695,6 +704,8 @@ export class RetryingCall implements Call, DeadlineInfoProvider { state: 'ACTIVE', call: child, nextMessageToSend: 0, + highestSentMessageIndex: -1, + halfCloseSent: false, startTime: new Date(), }); const previousAttempts = this.attempts - 1; @@ -778,6 +789,7 @@ export class RetryingCall implements Call, DeadlineInfoProvider { const bufferEntry = this.getBufferEntry(childCall.nextMessageToSend); switch (bufferEntry.entryType) { case 'MESSAGE': + childCall.highestSentMessageIndex = childCall.nextMessageToSend; childCall.call.sendMessageWithContext( { callback: error => { @@ -787,10 +799,26 @@ export class RetryingCall implements Call, DeadlineInfoProvider { }, bufferEntry.message!.message ); + // Optimization: if the next entry is HALF_CLOSE, send it immediately + // without waiting for the message callback. This is safe because the message + // has already been passed to the underlying transport. + const nextEntry = this.getBufferEntry(childCall.nextMessageToSend + 1); + if (nextEntry.entryType === 'HALF_CLOSE' && !childCall.halfCloseSent) { + this.trace( + 'Sending halfClose immediately after message to child [' + + childCall.call.getCallNumber() + + '] - optimizing for unary/final message' + ); + childCall.halfCloseSent = true; + childCall.call.halfClose(); + } break; case 'HALF_CLOSE': - childCall.nextMessageToSend += 1; - childCall.call.halfClose(); + if (!childCall.halfCloseSent) { + childCall.nextMessageToSend += 1; + childCall.halfCloseSent = true; + childCall.call.halfClose(); + } break; case 'FREED': // Should not be possible @@ -819,6 +847,7 @@ export class RetryingCall implements Call, DeadlineInfoProvider { call.state === 'ACTIVE' && call.nextMessageToSend === messageIndex ) { + call.highestSentMessageIndex = messageIndex; call.call.sendMessageWithContext( { callback: error => { @@ -839,6 +868,7 @@ export class RetryingCall implements Call, DeadlineInfoProvider { const call = this.underlyingCalls[this.committedCallIndex]; bufferEntry.callback = context.callback; if (call.state === 'ACTIVE' && call.nextMessageToSend === messageIndex) { + call.highestSentMessageIndex = messageIndex; call.call.sendMessageWithContext( { callback: error => { @@ -868,12 +898,22 @@ export class RetryingCall implements Call, DeadlineInfoProvider { allocated: false, }); for (const call of this.underlyingCalls) { - if ( - call?.state === 'ACTIVE' && - call.nextMessageToSend === halfCloseIndex - ) { - call.nextMessageToSend += 1; - call.call.halfClose(); + if (call?.state === 'ACTIVE' && !call.halfCloseSent) { + // Send halfClose immediately if all messages have been sent to this call + // We check highestSentMessageIndex >= halfCloseIndex - 1 because: + // - If halfCloseIndex is 0, there are no messages, so send immediately + // - If halfCloseIndex is N, the last message is at index N-1 + // - If highestSentMessageIndex >= N-1, all messages have been sent + if (halfCloseIndex === 0 || call.highestSentMessageIndex >= halfCloseIndex - 1) { + this.trace( + 'Sending halfClose immediately to child [' + + call.call.getCallNumber() + + '] - all messages already sent' + ); + call.halfCloseSent = true; + call.call.halfClose(); + } + // Otherwise, halfClose will be sent by sendNextChildMessage when messages complete } } } From 88a083d4bc7fb3d68dfb8aa2411ec072b9bd244e Mon Sep 17 00:00:00 2001 From: Serkan Erip Date: Wed, 10 Dec 2025 19:05:58 +0300 Subject: [PATCH 08/17] remove halfCloseSent field --- packages/grpc-js/src/retrying-call.ts | 45 +++++++++++---------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/packages/grpc-js/src/retrying-call.ts b/packages/grpc-js/src/retrying-call.ts index cbaec082c..733947200 100644 --- a/packages/grpc-js/src/retrying-call.ts +++ b/packages/grpc-js/src/retrying-call.ts @@ -128,10 +128,6 @@ interface UnderlyingCall { * This is different from nextMessageToSend which tracks completion/acknowledgment. */ highestSentMessageIndex: number; - /** - * Tracks whether halfClose has been sent to this child call. - */ - halfCloseSent: boolean; startTime: Date; } @@ -705,7 +701,6 @@ export class RetryingCall implements Call, DeadlineInfoProvider { call: child, nextMessageToSend: 0, highestSentMessageIndex: -1, - halfCloseSent: false, startTime: new Date(), }); const previousAttempts = this.attempts - 1; @@ -771,11 +766,10 @@ export class RetryingCall implements Call, DeadlineInfoProvider { this.maybeStartHedgingTimer(); } - private handleChildWriteCompleted(childIndex: number) { - const childCall = this.underlyingCalls[childIndex]; - const messageIndex = childCall.nextMessageToSend; + private handleChildWriteCompleted(childIndex: number, messageIndex: number) { this.getBufferEntry(messageIndex).callback?.(); this.clearSentMessages(); + const childCall = this.underlyingCalls[childIndex]; childCall.nextMessageToSend += 1; this.sendNextChildMessage(childIndex); } @@ -785,40 +779,38 @@ export class RetryingCall implements Call, DeadlineInfoProvider { if (childCall.state === 'COMPLETED') { return; } - if (this.getBufferEntry(childCall.nextMessageToSend)) { - const bufferEntry = this.getBufferEntry(childCall.nextMessageToSend); + const messageIndex = childCall.nextMessageToSend; + if (this.getBufferEntry(messageIndex)) { + const bufferEntry = this.getBufferEntry(messageIndex); switch (bufferEntry.entryType) { case 'MESSAGE': - childCall.highestSentMessageIndex = childCall.nextMessageToSend; childCall.call.sendMessageWithContext( { callback: error => { // Ignore error - this.handleChildWriteCompleted(childIndex); + this.handleChildWriteCompleted(childIndex, messageIndex); }, }, bufferEntry.message!.message ); + childCall.highestSentMessageIndex = messageIndex; // Optimization: if the next entry is HALF_CLOSE, send it immediately // without waiting for the message callback. This is safe because the message // has already been passed to the underlying transport. - const nextEntry = this.getBufferEntry(childCall.nextMessageToSend + 1); - if (nextEntry.entryType === 'HALF_CLOSE' && !childCall.halfCloseSent) { + const nextEntry = this.getBufferEntry(messageIndex + 1); + if (nextEntry.entryType === 'HALF_CLOSE') { this.trace( 'Sending halfClose immediately after message to child [' + childCall.call.getCallNumber() + '] - optimizing for unary/final message' ); - childCall.halfCloseSent = true; + childCall.nextMessageToSend += 1; childCall.call.halfClose(); } break; case 'HALF_CLOSE': - if (!childCall.halfCloseSent) { - childCall.nextMessageToSend += 1; - childCall.halfCloseSent = true; - childCall.call.halfClose(); - } + childCall.nextMessageToSend += 1; + childCall.call.halfClose(); break; case 'FREED': // Should not be possible @@ -847,16 +839,16 @@ export class RetryingCall implements Call, DeadlineInfoProvider { call.state === 'ACTIVE' && call.nextMessageToSend === messageIndex ) { - call.highestSentMessageIndex = messageIndex; call.call.sendMessageWithContext( { callback: error => { // Ignore error - this.handleChildWriteCompleted(callIndex); + this.handleChildWriteCompleted(callIndex, messageIndex); }, }, message ); + call.highestSentMessageIndex = messageIndex; } } } else { @@ -873,7 +865,7 @@ export class RetryingCall implements Call, DeadlineInfoProvider { { callback: error => { // Ignore error - this.handleChildWriteCompleted(this.committedCallIndex!); + this.handleChildWriteCompleted(this.committedCallIndex!, messageIndex); }, }, message @@ -898,19 +890,18 @@ export class RetryingCall implements Call, DeadlineInfoProvider { allocated: false, }); for (const call of this.underlyingCalls) { - if (call?.state === 'ACTIVE' && !call.halfCloseSent) { + if (call?.state === 'ACTIVE') { // Send halfClose immediately if all messages have been sent to this call // We check highestSentMessageIndex >= halfCloseIndex - 1 because: - // - If halfCloseIndex is 0, there are no messages, so send immediately // - If halfCloseIndex is N, the last message is at index N-1 // - If highestSentMessageIndex >= N-1, all messages have been sent - if (halfCloseIndex === 0 || call.highestSentMessageIndex >= halfCloseIndex - 1) { + if (call.highestSentMessageIndex >= halfCloseIndex - 1) { this.trace( 'Sending halfClose immediately to child [' + call.call.getCallNumber() + '] - all messages already sent' ); - call.halfCloseSent = true; + call.nextMessageToSend += 1; call.call.halfClose(); } // Otherwise, halfClose will be sent by sendNextChildMessage when messages complete From 4d387d484885c92ecc9e598e8bc5ee3db1e6ab6a Mon Sep 17 00:00:00 2001 From: Serkan Erip Date: Wed, 10 Dec 2025 20:03:43 +0300 Subject: [PATCH 09/17] Use nextMessageToSend for early half-close --- packages/grpc-js/src/retrying-call.ts | 27 ++++++++++-------------- packages/grpc-js/test/test-end-to-end.ts | 27 +++++++++++++++++++++++- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/grpc-js/src/retrying-call.ts b/packages/grpc-js/src/retrying-call.ts index 733947200..61ff58fa1 100644 --- a/packages/grpc-js/src/retrying-call.ts +++ b/packages/grpc-js/src/retrying-call.ts @@ -123,11 +123,6 @@ interface UnderlyingCall { state: UnderlyingCallState; call: LoadBalancingCall; nextMessageToSend: number; - /** - * Tracks the highest message index that has been sent to the underlying call. - * This is different from nextMessageToSend which tracks completion/acknowledgment. - */ - highestSentMessageIndex: number; startTime: Date; } @@ -700,7 +695,6 @@ export class RetryingCall implements Call, DeadlineInfoProvider { state: 'ACTIVE', call: child, nextMessageToSend: 0, - highestSentMessageIndex: -1, startTime: new Date(), }); const previousAttempts = this.attempts - 1; @@ -793,7 +787,6 @@ export class RetryingCall implements Call, DeadlineInfoProvider { }, bufferEntry.message!.message ); - childCall.highestSentMessageIndex = messageIndex; // Optimization: if the next entry is HALF_CLOSE, send it immediately // without waiting for the message callback. This is safe because the message // has already been passed to the underlying transport. @@ -833,7 +826,11 @@ export class RetryingCall implements Call, DeadlineInfoProvider { }; this.writeBuffer.push(bufferEntry); if (bufferEntry.allocated) { - context.callback?.(); + // Run this in next tick to avoid suspending the current execution context + // otherwise it might cause half closing the call before sending message + process.nextTick(() => { + context.callback?.(); + }); for (const [callIndex, call] of this.underlyingCalls.entries()) { if ( call.state === 'ACTIVE' && @@ -848,7 +845,6 @@ export class RetryingCall implements Call, DeadlineInfoProvider { }, message ); - call.highestSentMessageIndex = messageIndex; } } } else { @@ -860,7 +856,6 @@ export class RetryingCall implements Call, DeadlineInfoProvider { const call = this.underlyingCalls[this.committedCallIndex]; bufferEntry.callback = context.callback; if (call.state === 'ACTIVE' && call.nextMessageToSend === messageIndex) { - call.highestSentMessageIndex = messageIndex; call.call.sendMessageWithContext( { callback: error => { @@ -891,11 +886,11 @@ export class RetryingCall implements Call, DeadlineInfoProvider { }); for (const call of this.underlyingCalls) { if (call?.state === 'ACTIVE') { - // Send halfClose immediately if all messages have been sent to this call - // We check highestSentMessageIndex >= halfCloseIndex - 1 because: - // - If halfCloseIndex is N, the last message is at index N-1 - // - If highestSentMessageIndex >= N-1, all messages have been sent - if (call.highestSentMessageIndex >= halfCloseIndex - 1) { + // Send halfClose to call when either: + // - nextMessageToSend === halfCloseIndex - 1: last message sent, callback pending (optimization) + // - nextMessageToSend === halfCloseIndex: all messages sent and acknowledged + if (call.nextMessageToSend === halfCloseIndex + || call.nextMessageToSend === halfCloseIndex - 1) { this.trace( 'Sending halfClose immediately to child [' + call.call.getCallNumber() + @@ -904,7 +899,7 @@ export class RetryingCall implements Call, DeadlineInfoProvider { call.nextMessageToSend += 1; call.call.halfClose(); } - // Otherwise, halfClose will be sent by sendNextChildMessage when messages complete + // Otherwise, halfClose will be sent by sendNextChildMessage when message callbacks complete } } } diff --git a/packages/grpc-js/test/test-end-to-end.ts b/packages/grpc-js/test/test-end-to-end.ts index c7de2d6a6..676b4cff0 100644 --- a/packages/grpc-js/test/test-end-to-end.ts +++ b/packages/grpc-js/test/test-end-to-end.ts @@ -18,7 +18,7 @@ import * as assert from 'assert'; import * as path from 'path'; import { loadProtoFile } from './common'; -import { Metadata, Server, ServerDuplexStream, ServerUnaryCall, ServiceClientConstructor, ServiceError, experimental, sendUnaryData } from '../src'; +import { Metadata, Server, ServerCredentials, ServerDuplexStream, ServerReadableStream, ServerUnaryCall, ServiceClientConstructor, ServiceError, credentials, experimental, sendUnaryData } from '../src'; import { ServiceClient } from '../src/make-client'; const protoFile = path.join(__dirname, 'fixtures', 'echo_service.proto'); @@ -36,6 +36,15 @@ const echoServiceImplementation = { call.end(); }); }, + echoClientStream(call: ServerReadableStream, callback: sendUnaryData) { + const messages: any[] = []; + call.on('data', (message: any) => { + messages.push(message); + }); + call.on('end', () => { + callback(null, { value: messages.map(m => m.value).join(','), value2: messages.length }); + }); + }, }; describe('Client should successfully communicate with server', () => { @@ -77,4 +86,20 @@ describe('Client should successfully communicate with server', () => { }); }); }).timeout(5000); + + it('Client streaming with one message should work', done => { + server = new Server(); + server.addService(EchoService.service, echoServiceImplementation); + server.bindAsync('localhost:0', ServerCredentials.createInsecure(), (error, port) => { + assert.ifError(error); + client = new EchoService(`localhost:${port}`, credentials.createInsecure()); + const call = client.echoClientStream((error: ServiceError, response: any) => { + assert.ifError(error); + assert.deepStrictEqual(response, { value: 'test value', value2: 1 }); + done(); + }); + call.write({ value: 'test value', value2: 42 }); + call.end(); + }); + }); }); From 9ea15ce37548b817640e56541a08d8724f36de2c Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Wed, 10 Dec 2025 16:14:39 -0500 Subject: [PATCH 10/17] grpc-js: Bump version to 1.14.3 --- packages/grpc-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grpc-js/package.json b/packages/grpc-js/package.json index b157dc37c..84b742fcb 100644 --- a/packages/grpc-js/package.json +++ b/packages/grpc-js/package.json @@ -1,6 +1,6 @@ { "name": "@grpc/grpc-js", - "version": "1.14.2", + "version": "1.14.3", "description": "gRPC Library for Node - pure JS implementation", "homepage": "https://grpc.io/", "repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js", From 4f3c58fda2136eb0038a39d54804acb06a8419ea Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Thu, 12 Mar 2026 13:50:10 -0700 Subject: [PATCH 11/17] grpc-js-xds: Update RBAC code to handle Node type change, pin @types/node --- packages/grpc-js-xds/package.json | 2 +- packages/grpc-js-xds/src/rbac.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/grpc-js-xds/package.json b/packages/grpc-js-xds/package.json index 15b8d65a4..7b331caac 100644 --- a/packages/grpc-js-xds/package.json +++ b/packages/grpc-js-xds/package.json @@ -38,7 +38,7 @@ "@types/gulp": "^4.0.6", "@types/gulp-mocha": "0.0.32", "@types/mocha": "^5.2.6", - "@types/node": ">=20.11.20", + "@types/node": "25.5.0", "@types/yargs": "^15.0.5", "grpc-health-check": "file:../grpc-health-check", "gts": "^5.0.1", diff --git a/packages/grpc-js-xds/src/rbac.ts b/packages/grpc-js-xds/src/rbac.ts index b1d8558f0..31f8cb0cc 100644 --- a/packages/grpc-js-xds/src/rbac.ts +++ b/packages/grpc-js-xds/src/rbac.ts @@ -225,7 +225,14 @@ export class AuthenticatedPrincipal implements PrincipalRule { } } } - return this.nameMatcher.apply(info.peerCertificate.subject.CN); + if (info.peerCertificate.subject.CN) { + if (Array.isArray(info.peerCertificate.subject.CN)) { + return info.peerCertificate.subject.CN.some(entry => this.nameMatcher!.apply(entry)); + } else { + return this.nameMatcher.apply(info.peerCertificate.subject.CN); + } + } + return false; } toString(): string { return `Authenticated(principal=${this.nameMatcher?.toString() ?? null})`; From 234f9172b2ff35e586ca7d4e788557aad5985668 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Wed, 13 May 2026 14:02:47 -0700 Subject: [PATCH 12/17] Fix server crash when handling invalid requests --- packages/grpc-js/src/server-interceptors.ts | 10 +--------- packages/grpc-js/src/server.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/grpc-js/src/server-interceptors.ts b/packages/grpc-js/src/server-interceptors.ts index a7cddd933..8ba3b6293 100644 --- a/packages/grpc-js/src/server-interceptors.ts +++ b/packages/grpc-js/src/server-interceptors.ts @@ -19,7 +19,7 @@ import { PartialStatusObject } from './call-interface'; import { ServerMethodDefinition } from './make-client'; import { Metadata } from './metadata'; import { ChannelOptions } from './channel-options'; -import { Handler, ServerErrorResponse } from './server-call'; +import { Handler } from './server-call'; import { Deadline } from './deadline'; import { DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH, @@ -554,14 +554,6 @@ export class BaseServerInterceptingCall private readonly handler: Handler, options: ChannelOptions ) { - this.stream.once('error', (err: ServerErrorResponse) => { - /* We need an error handler to avoid uncaught error event exceptions, but - * there is nothing we can reasonably do here. Any error event should - * have a corresponding close event, which handles emitting the cancelled - * event. And the stream is now in a bad state, so we can't reasonably - * expect to be able to send an error over it. */ - }); - this.stream.once('close', () => { trace( 'Request to method ' + diff --git a/packages/grpc-js/src/server.ts b/packages/grpc-js/src/server.ts index 73d84b787..205832d73 100644 --- a/packages/grpc-js/src/server.ts +++ b/packages/grpc-js/src/server.ts @@ -1336,6 +1336,13 @@ export class Server { stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders ) { + stream.once('error', (err: ServerErrorResponse) => { + /* We need an error handler to avoid uncaught error event exceptions, but + * there is nothing we can reasonably do here. Any error event should + * have a corresponding close event, which handles emitting the cancelled + * event. And the stream is now in a bad state, so we can't reasonably + * expect to be able to send an error over it. */ + }); // for handling idle timeout this.onStreamOpened(stream); @@ -1420,6 +1427,13 @@ export class Server { stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders ) { + stream.once('error', (err: ServerErrorResponse) => { + /* We need an error handler to avoid uncaught error event exceptions, but + * there is nothing we can reasonably do here. Any error event should + * have a corresponding close event, which handles emitting the cancelled + * event. And the stream is now in a bad state, so we can't reasonably + * expect to be able to send an error over it. */ + }); // for handling idle timeout this.onStreamOpened(stream); From 2fe55fd76a8bb59eaab5f39e3552b5f84985a163 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Wed, 13 May 2026 15:24:40 -0700 Subject: [PATCH 13/17] Fix crashes when receiving malformed compressed data --- packages/grpc-js/src/compression-filter.ts | 12 ++++++++++++ packages/grpc-js/src/server-interceptors.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/packages/grpc-js/src/compression-filter.ts b/packages/grpc-js/src/compression-filter.ts index e4428a1fb..5277d8c1c 100644 --- a/packages/grpc-js/src/compression-filter.ts +++ b/packages/grpc-js/src/compression-filter.ts @@ -119,6 +119,12 @@ class DeflateHandler extends CompressionHandler { let totalLength = 0; const messageParts: Buffer[] = []; const decompresser = zlib.createInflate(); + decompresser.on('error', (error: Error) => { + reject({ + code: Status.INTERNAL, + details: 'Failed to decompress deflate-encoded message' + }); + }); decompresser.on('data', (chunk: Buffer) => { messageParts.push(chunk); totalLength += chunk.byteLength; @@ -161,6 +167,12 @@ class GzipHandler extends CompressionHandler { let totalLength = 0; const messageParts: Buffer[] = []; const decompresser = zlib.createGunzip(); + decompresser.on('error', (error: Error) => { + reject({ + code: Status.INTERNAL, + details: 'Failed to decompress gzip-encoded message' + }); + }); decompresser.on('data', (chunk: Buffer) => { messageParts.push(chunk); totalLength += chunk.byteLength; diff --git a/packages/grpc-js/src/server-interceptors.ts b/packages/grpc-js/src/server-interceptors.ts index a7cddd933..6b4651f7c 100644 --- a/packages/grpc-js/src/server-interceptors.ts +++ b/packages/grpc-js/src/server-interceptors.ts @@ -744,6 +744,12 @@ export class BaseServerInterceptingCall return new Promise((resolve, reject) => { let totalLength = 0 const messageParts: Buffer[] = []; + decompresser.on('error', (error: Error) => { + reject({ + code: Status.INTERNAL, + details: `Failed to decompress ${encoding}-encoded message` + }); + }); decompresser.on('data', (chunk: Buffer) => { messageParts.push(chunk); totalLength += chunk.byteLength; From 5029a2668164d1ba6de6ed4dcf6d35d5c4ff6cf4 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Mon, 18 May 2026 11:23:52 -0700 Subject: [PATCH 14/17] Make compression error a static string --- packages/grpc-js/src/server-interceptors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grpc-js/src/server-interceptors.ts b/packages/grpc-js/src/server-interceptors.ts index 6b4651f7c..3cbf8b3c6 100644 --- a/packages/grpc-js/src/server-interceptors.ts +++ b/packages/grpc-js/src/server-interceptors.ts @@ -747,7 +747,7 @@ export class BaseServerInterceptingCall decompresser.on('error', (error: Error) => { reject({ code: Status.INTERNAL, - details: `Failed to decompress ${encoding}-encoded message` + details: 'Failed to decompress message' }); }); decompresser.on('data', (chunk: Buffer) => { From e5e0b1d3ff14fa7c5eeef10b309d694bc3ff7e96 Mon Sep 17 00:00:00 2001 From: Michael Lumish Date: Mon, 18 May 2026 13:55:00 -0700 Subject: [PATCH 15/17] grpc-js: Bump version to 1.14.4 --- packages/grpc-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grpc-js/package.json b/packages/grpc-js/package.json index 84b742fcb..4cf0a284c 100644 --- a/packages/grpc-js/package.json +++ b/packages/grpc-js/package.json @@ -1,6 +1,6 @@ { "name": "@grpc/grpc-js", - "version": "1.14.3", + "version": "1.14.4", "description": "gRPC Library for Node - pure JS implementation", "homepage": "https://grpc.io/", "repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js", From 49da44687edfa6d982224731655ae33e661d8ca4 Mon Sep 17 00:00:00 2001 From: ws-gregm <55088361+ws-gregm@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:53:40 +0000 Subject: [PATCH 16/17] grpc-js: stop keepalive pings once a session is draining after GOAWAY A graceful GOAWAY leaves the keepalive timer running on both the client transport and the server session. The next PING that fails or times out calls the disconnect path, which destroys in-flight RPCs that the GOAWAY had deliberately allowed to finish draining. Client (transport.ts): mark the transport as draining when the session emits goaway, clear any pending keepalive timeout, refuse to start new pings while draining, and ignore the completion callback of a ping that was already in flight when the GOAWAY arrived. The socket close/error handlers still report genuine disconnects, and call deadlines still bound hung calls. Server (server.ts): begin draining a session before any locally initiated close (closeSession, max connection age) and when the peer sends GOAWAY, so the keepalive machinery cannot destroy a session that is intentionally winding down with calls still active. See grpc/grpc-node#3068 for reproductions of both the timer-driven failure and the ping-already-in-flight race. Co-Authored-By: Claude Fable 5 --- packages/grpc-js/src/server.ts | 42 +++++++++++++++++++++++++++++++ packages/grpc-js/src/transport.ts | 16 ++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/grpc-js/src/server.ts b/packages/grpc-js/src/server.ts index 205832d73..1c2b31f4b 100644 --- a/packages/grpc-js/src/server.ts +++ b/packages/grpc-js/src/server.ts @@ -279,6 +279,11 @@ export class Server { UntypedHandler >(); private sessions = new Map(); + private drainingSessions = new WeakSet(); + private sessionDrainHandlers = new WeakMap< + http2.ServerHttp2Session, + () => void + >(); /** * This field only exists to ensure that the start method throws an error if * it is called twice, as it did previously. @@ -1034,11 +1039,20 @@ export class Server { }); } + private beginSessionDrain(session: http2.ServerHttp2Session) { + /* A PING on a locally closing session bypasses graceful drain by + * destroying active streams, so disable keepalive before close. */ + this.drainingSessions.add(session); + const drainHandler = this.sessionDrainHandlers.get(session); + drainHandler?.(); + } + private closeSession( session: http2.ServerHttp2Session, callback?: () => void ) { this.trace('Closing session initiated by ' + session.socket?.remoteAddress); + this.beginSessionDrain(session); const sessionInfo = this.sessions.get(session); const closeCallback = () => { if (sessionInfo) { @@ -1545,6 +1559,7 @@ export class Server { connectionAgeTimer = setTimeout(() => { sessionClosedByServer = true; + this.beginSessionDrain(session); this.trace( 'Connection dropped by max connection age: ' + @@ -1583,8 +1598,14 @@ export class Server { } }; + this.sessionDrainHandlers.set(session, clearKeepaliveTimeout); + session.once('goaway', () => { + this.beginSessionDrain(session); + }); + const canSendPing = () => { return ( + !this.drainingSessions.has(session) && !session.destroyed && this.keepaliveTimeMs < KEEPALIVE_MAX_TIME_MS && this.keepaliveTimeMs > 0 @@ -1620,6 +1641,12 @@ export class Server { const pingSentSuccessfully = session.ping( (err: Error | null, duration: number, payload: Buffer) => { clearKeepaliveTimeout(); + if (this.drainingSessions.has(session)) { + this.keepaliveTrace( + 'Ignoring ping result on draining server session' + ); + return; + } if (err) { this.keepaliveTrace('Ping failed with error: ' + err.message); sessionClosedByServer = true; @@ -1677,6 +1704,7 @@ export class Server { } clearKeepaliveTimeout(); + this.sessionDrainHandlers.delete(session); if (idleTimeoutObj !== null) { clearTimeout(idleTimeoutObj.timeout); @@ -1733,6 +1761,7 @@ export class Server { connectionAgeTimer = setTimeout(() => { sessionClosedByServer = true; + this.beginSessionDrain(session); this.channelzTrace.addTrace( 'CT_INFO', 'Connection dropped by max connection age from ' + clientAddress @@ -1770,8 +1799,14 @@ export class Server { } }; + this.sessionDrainHandlers.set(session, clearKeepaliveTimeout); + session.once('goaway', () => { + this.beginSessionDrain(session); + }); + const canSendPing = () => { return ( + !this.drainingSessions.has(session) && !session.destroyed && this.keepaliveTimeMs < KEEPALIVE_MAX_TIME_MS && this.keepaliveTimeMs > 0 @@ -1807,6 +1842,12 @@ export class Server { const pingSentSuccessfully = session.ping( (err: Error | null, duration: number, payload: Buffer) => { clearKeepaliveTimeout(); + if (this.drainingSessions.has(session)) { + this.keepaliveTrace( + 'Ignoring ping result on draining server session' + ); + return; + } if (err) { this.keepaliveTrace('Ping failed with error: ' + err.message); this.channelzTrace.addTrace( @@ -1881,6 +1922,7 @@ export class Server { } clearKeepaliveTimeout(); + this.sessionDrainHandlers.delete(session); if (idleTimeoutObj !== null) { clearTimeout(idleTimeoutObj.timeout); diff --git a/packages/grpc-js/src/transport.ts b/packages/grpc-js/src/transport.ts index a1cca59e5..894a42715 100644 --- a/packages/grpc-js/src/transport.ts +++ b/packages/grpc-js/src/transport.ts @@ -121,6 +121,12 @@ class Http2Transport implements Transport { */ private pendingSendKeepalivePing = false; + /** + * Indicates that the peer sent a GOAWAY, so this connection is draining and + * must not be probed with further keepalive pings. + */ + private isDraining = false; + private userAgent: string; private activeCalls: Set = new Set(); @@ -214,6 +220,8 @@ class Http2Transport implements Transport { ) { tooManyPings = true; } + this.isDraining = true; + this.clearKeepaliveTimeout(); this.trace( 'connection closed by GOAWAY with code ' + errorCode + @@ -415,6 +423,7 @@ class Http2Transport implements Transport { private canSendPing() { return ( + !this.isDraining && !this.session.destroyed && this.keepaliveTimeMs > 0 && (this.keepaliveWithoutCalls || this.activeCalls.size > 0) @@ -422,6 +431,9 @@ class Http2Transport implements Transport { } private maybeSendPing() { + if (this.isDraining) { + return; + } if (!this.canSendPing()) { this.pendingSendKeepalivePing = true; return; @@ -447,6 +459,10 @@ class Http2Transport implements Transport { const pingSentSuccessfully = this.session.ping( (err: Error | null, duration: number, payload: Buffer) => { this.clearKeepaliveTimeout(); + if (this.isDraining) { + this.keepaliveTrace('Ignoring ping result on draining transport'); + return; + } if (err) { this.keepaliveTrace('Ping failed with error ' + err.message); this.handleDisconnect(); From f9344c269c43cf52a876eec6f747cdbebd6c7e60 Mon Sep 17 00:00:00 2001 From: ws-gregm <55088361+ws-gregm@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:03:57 +0000 Subject: [PATCH 17/17] fork: publish @wingspanhq/grpc-js to GitHub Packages Rename the grpc-js package to @wingspanhq/grpc-js at 1.14.4-wingspan.1, add the publish workflow and tsconfig.publish.json (src-only compile of the published build/src tree), and document the fork's purpose, build recipe, upstream-rebase procedure, and exit criteria in FORK.md. The copied ORCA protos and the build output stay untracked; copy-protos and tsc regenerate them at publish time. Co-Authored-By: Claude Fable 5 --- .../workflows/publish-wingspan-grpc-js.yml | 53 +++++++++ FORK.md | 104 ++++++++++++++++++ packages/grpc-js/package.json | 14 ++- packages/grpc-js/tsconfig.publish.json | 14 +++ 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/publish-wingspan-grpc-js.yml create mode 100644 FORK.md create mode 100644 packages/grpc-js/tsconfig.publish.json diff --git a/.github/workflows/publish-wingspan-grpc-js.yml b/.github/workflows/publish-wingspan-grpc-js.yml new file mode 100644 index 000000000..9ae24fc3b --- /dev/null +++ b/.github/workflows/publish-wingspan-grpc-js.yml @@ -0,0 +1,53 @@ +name: Publish @wingspanhq/grpc-js + +on: + push: + branches: + - 'wingspan/**' + paths: + - 'packages/grpc-js/**' + - '.github/workflows/publish-wingspan-grpc-js.yml' + workflow_dispatch: + +jobs: + publish: + name: build and publish to GitHub Packages + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Init proto submodules + run: git submodule update --init packages/grpc-js-xds/deps/xds packages/grpc-js-xds/deps/protoc-gen-validate + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://npm.pkg.github.com' + scope: '@wingspanhq' + # --ignore-scripts everywhere: the sibling packages' prepare scripts do + # not build in isolation, and generated proto types are checked in, so + # nothing needs regenerating. proto-loader is built explicitly because + # grpc-js consumes it as a file: devDependency (a symlink) and needs its + # build/ output for type resolution. + - name: Build proto-loader (sibling devDependency) + run: | + npm install --ignore-scripts + ./node_modules/.bin/tsc -p . + working-directory: packages/proto-loader + - name: Install grpc-js dependencies + run: npm install --ignore-scripts + working-directory: packages/grpc-js + - name: Copy ORCA protos + run: node copy-protos.js + working-directory: packages/grpc-js + - name: Compile + run: ./node_modules/.bin/tsc -p tsconfig.publish.json + working-directory: packages/grpc-js + - name: Publish + run: npm publish --ignore-scripts + working-directory: packages/grpc-js + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000..2f689f997 --- /dev/null +++ b/FORK.md @@ -0,0 +1,104 @@ +# Wingspan fork of grpc/grpc-node + +This is Wingspan's fork of [grpc/grpc-node](https://github.com/grpc/grpc-node). It exists to +publish **`@wingspanhq/grpc-js`** — a build of `@grpc/grpc-js` that carries one behavioral fix — +to GitHub Packages. No other package in this monorepo is published from the fork. + +## Why this fork exists + +A graceful HTTP/2 GOAWAY leaves grpc-js's keepalive timer running on both the client transport +and the server session. The next PING that fails or times out triggers the disconnect path, +destroying in-flight RPCs the GOAWAY had deliberately allowed to finish draining. In production +this surfaced as status 14 / `Connection dropped` on calls that were completing normally during +deploys and connection-age recycling. + +Reported upstream as [grpc/grpc-node#3068](https://github.com/grpc/grpc-node/issues/3068). +Upstream has pushed back on the premise (RFC 9113 requires a PING to be ACKed regardless of +GOAWAY), so this fix may never merge upstream in its current form. Until that conversation +resolves, this fork is the delivery vehicle. It replaces the previous mechanism — a +`patch-package` postinstall hook in `@wingspanhq/grpc` that rewrote grpc-js's compiled output in +consumers' `node_modules` — which was fragile across package managers and install layouts +(see wingspanHQ/grpc#89). + +## What is changed relative to upstream + +Branch naming: `wingspan/grpc-js-`, based on the upstream release tag +`@grpc/grpc-js@`. Each branch contains exactly two kinds of commits: + +1. **The fix** — `packages/grpc-js/src/transport.ts` and `packages/grpc-js/src/server.ts` only. + Client: mark the transport draining on `goaway`, clear the keepalive timeout, refuse new + pings, ignore in-flight ping completions. Server: begin draining before locally initiated + closes (`closeSession`, max connection age) and on peer GOAWAY. Kept free of fork-identity + noise so it can be cherry-picked into an upstream PR. +2. **Fork identity** — package rename to `@wingspanhq/grpc-js`, version `X.Y.Z-wingspan.N`, + GitHub Packages `publishConfig`, this file, and the publish workflow. + +`master` tracks upstream and carries no Wingspan changes. + +## How it is consumed + +`@wingspanhq/grpc` (the internal gRPC framework) depends on it via an npm alias: + +```json +"@grpc/grpc-js": "npm:@wingspanhq/grpc-js@1.14.4-wingspan.1" +``` + +so every `import from '@grpc/grpc-js'` — including deep imports and `@grpc/grpc-js-xds`'s peer +resolution — lands on the fork. Services do not (and should not) depend on this package +directly; it arrives transitively through `@wingspanhq/grpc`. + +Expect a yarn peer-dependency warning from `@grpc/grpc-js-xds` (`~1.14.0` does not match a +prerelease version). It is benign: the alias puts the fork at `node_modules/@grpc/grpc-js`, +which is what xds resolves. + +## Publishing a new version + +The `Publish @wingspanhq/grpc-js` workflow (`.github/workflows/publish-wingspan-grpc-js.yml`) +runs on pushes to `wingspan/**` branches that touch `packages/grpc-js/`, or manually via +workflow dispatch. It builds from checked-in generated types (no proto regeneration) and +publishes to GitHub Packages with the repo's `GITHUB_TOKEN`. + +To publish locally instead (requires a token with `write:packages` for wingspanHQ): + +```bash +git submodule update --init packages/grpc-js-xds/deps/xds packages/grpc-js-xds/deps/protoc-gen-validate +(cd packages/proto-loader && npm install --ignore-scripts && ./node_modules/.bin/tsc -p .) +cd packages/grpc-js +npm install --ignore-scripts +node copy-protos.js +./node_modules/.bin/tsc -p tsconfig.publish.json +npm publish --ignore-scripts +``` + +`--ignore-scripts` on install stops the sibling packages' `prepare` scripts, which do not build +in isolation; on publish it skips `prepare`, which would regenerate checked-in proto types with +tooling this flow deliberately avoids. proto-loader must be compiled first because grpc-js +consumes it as a `file:` devDependency (a symlink) and resolves types from its `build/` output. +`tsconfig.publish.json` compiles `src/` only (the published `build/src` tree) with node types +included explicitly; the stock `tsconfig.json` targets upstream's gulp pipeline and compiles +tests too. + +## Tracking a new upstream release + +When upstream tags `@grpc/grpc-js@X.Y.Z`: + +```bash +git fetch upstream --tags # upstream = https://github.com/grpc/grpc-node.git +git checkout -b wingspan/grpc-js-X.Y.Z "@grpc/grpc-js@X.Y.Z" +git cherry-pick # commit 1 from the previous wingspan/ branch +git cherry-pick # then bump version to X.Y.Z-wingspan.1 +``` + +Resolve conflicts in the two touched source files by re-reading the surrounding upstream code — +the fix is small and its anchors (goaway handler, `canSendPing`, ping callback, `closeSession`, +connection-age timers) are stable but not guaranteed. After building, compare the compiled +`transport.js`/`server.js` against the previous branch's output to review what upstream changed +underneath the fix. Then update the alias version and the expected SHA-256 digests in +`@wingspanhq/grpc` (`src/grpcJsPatchIntegrity.ts`) — its startup integrity check hashes the +compiled files and fails closed on drift. + +## Exit criteria + +If upstream ships an equivalent fix (or #3068 concludes with a config-level answer), point +`@wingspanhq/grpc` back at stock `@grpc/grpc-js`, delete the alias and digests bump, and archive +this fork. diff --git a/packages/grpc-js/package.json b/packages/grpc-js/package.json index 4cf0a284c..4ef05e5d2 100644 --- a/packages/grpc-js/package.json +++ b/packages/grpc-js/package.json @@ -1,9 +1,9 @@ { - "name": "@grpc/grpc-js", - "version": "1.14.4", - "description": "gRPC Library for Node - pure JS implementation", + "name": "@wingspanhq/grpc-js", + "version": "1.14.4-wingspan.1", + "description": "Wingspan fork of @grpc/grpc-js carrying the GOAWAY/keepalive drain fix (grpc/grpc-node#3068)", "homepage": "https://grpc.io/", - "repository": "https://github.com/grpc/grpc-node/tree/master/packages/grpc-js", + "repository": "git+https://github.com/wingspanHQ/grpc-node.git", "main": "build/src/index.js", "engines": { "node": ">=12.10.0" @@ -85,5 +85,9 @@ "deps/googleapis/google/api/*.proto", "deps/googleapis/google/rpc/*.proto", "deps/protoc-gen-validate/validate/**/*.proto" - ] + ], + "publishConfig": { + "registry": "https://npm.pkg.github.com/", + "access": "restricted" + } } diff --git a/packages/grpc-js/tsconfig.publish.json b/packages/grpc-js/tsconfig.publish.json new file mode 100644 index 000000000..350bf5e15 --- /dev/null +++ b/packages/grpc-js/tsconfig.publish.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false, + "types": [ + "node" + ], + "rootDir": "src", + "outDir": "build/src" + }, + "include": [ + "src/**/*.ts" + ] +}