From 9aac85a281a97b87eb2ee9558ac3f0291dfd0ea0 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Mon, 3 Aug 2026 11:48:39 +0300 Subject: [PATCH 1/3] feat(graphql): reply with pong when the server sends a ping The graphql-transport-ws protocol defines ping/pong as bidirectional and requires the receiving party to answer a ping as soon as possible. The client only ever sent pings, so a server-initiated ping fell through to 'unrecognized' and was silently dropped. Inert on the legacy endpoint, which never initiates a ping (its heartbeat is 'ka'); this prepares the client for /graphql/subscription-v2, whose handler is configured with a 10s keep-alive that pings idle connections. Co-Authored-By: Claude Opus 4.8 --- .../graphql/__tests__/graphql-subscriptions.spec.ts | 13 +++++++++++++ .../graphql/__tests__/subscription-protocol.spec.ts | 10 ++++++++++ .../src/lib/services/graphql/graphql.service.ts | 6 ++++++ .../lib/services/graphql/subscription-protocol.ts | 13 +++++++++++++ 4 files changed, 42 insertions(+) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-subscriptions.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-subscriptions.spec.ts index f13e138a..7220223f 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-subscriptions.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-subscriptions.spec.ts @@ -245,6 +245,19 @@ describe('GraphQL subscriptions', () => { subscription.unsubscribe(); }); + it('replies with a pong when the server sends a ping', async () => { + const subscription = fixture.triggerSubscription(); + + await fixture.handleConnectionInit(); + await fixture.consumeSubscribeMessage(); + + fixture.sendMessageToClient({ type: 'ping' }); + + expect(await fixture.getNextMessage()).toEqual({ type: 'pong' }); + + subscription.unsubscribe(); + }); + it('when the server sends an error, it will reconnect and subscribe again', async () => { const subscription = fixture.triggerSubscription(); useFakeSetInterval(); diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/subscription-protocol.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/subscription-protocol.spec.ts index 01005f20..b52111c4 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/subscription-protocol.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/subscription-protocol.spec.ts @@ -32,6 +32,10 @@ describe('LegacySubscriptionProtocol', () => { it('serializes ping without id and payload', () => { expect(JSON.parse(protocol.serializePing())).toEqual({ type: 'ping' }); }); + + it('serializes pong without id and payload', () => { + expect(JSON.parse(protocol.serializePong())).toEqual({ type: 'pong' }); + }); }); describe('incoming frames', () => { @@ -47,6 +51,12 @@ describe('LegacySubscriptionProtocol', () => { ).toEqual({ type: 'connection-ack' }); }); + it('parses a server-sent ping', () => { + expect(protocol.parseIncomingMessage('{"type":"ping"}')).toEqual({ + type: 'ping', + }); + }); + it('parses pong', () => { expect(protocol.parseIncomingMessage('{"type":"pong"}')).toEqual({ type: 'pong', diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index b38c4980..028ce5bb 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -542,6 +542,12 @@ export class GraphqlService { break; } + case 'ping': + // The protocol requires the receiving party to answer a ping as soon + // as possible. Only the newer endpoint pings us; the legacy one never does. + this.sendRawMessage(this.protocol.serializePong()); + break; + case 'pong': clearTimeout(this.pongTimeout); this.connectionAttemptsCount = 0; diff --git a/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts b/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts index 9f9c8287..61ee2410 100644 --- a/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts +++ b/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts @@ -17,6 +17,7 @@ export interface QminderGraphQLError { export type IncomingSubscriptionMessage = | { readonly type: 'connection-ack' } | { readonly type: 'keep-alive' } + | { readonly type: 'ping' } | { readonly type: 'pong' } | { readonly type: 'data'; @@ -45,6 +46,11 @@ export interface SubscriptionProtocol { serializeSubscribe(id: string, query: string): string; serializeUnsubscribe(id: string): string; serializePing(): string; + /** + * A `pong` answering a `ping` from the server. The `graphql-transport-ws` + * protocol requires the receiving party to answer a ping as soon as possible. + */ + serializePong(): string; parseIncomingMessage(data: string): IncomingSubscriptionMessage; } @@ -106,6 +112,10 @@ export class LegacySubscriptionProtocol implements SubscriptionProtocol { return JSON.stringify({ type: LegacyMessageType.GQL_PING }); } + serializePong(): string { + return JSON.stringify({ type: LegacyMessageType.GQL_PONG }); + } + parseIncomingMessage(data: string): IncomingSubscriptionMessage { const message: LegacyMessage = JSON.parse(data); @@ -116,6 +126,9 @@ export class LegacySubscriptionProtocol implements SubscriptionProtocol { case LegacyMessageType.GQL_CONNECTION_ACK: return { type: 'connection-ack' }; + case LegacyMessageType.GQL_PING: + return { type: 'ping' }; + case LegacyMessageType.GQL_PONG: return { type: 'pong' }; From c6fe42578bb51240c189690a7a2a237171cfecee Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Mon, 3 Aug 2026 11:52:20 +0300 Subject: [PATCH 2/3] Update graphql.service.ts --- .../javascript-api/src/lib/services/graphql/graphql.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 028ce5bb..702d50b3 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -543,8 +543,6 @@ export class GraphqlService { } case 'ping': - // The protocol requires the receiving party to answer a ping as soon - // as possible. Only the newer endpoint pings us; the legacy one never does. this.sendRawMessage(this.protocol.serializePong()); break; From 114e44bc44cc89f9b88fa1ab139143448816e82d Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Mon, 3 Aug 2026 11:52:43 +0300 Subject: [PATCH 3/3] Update subscription-protocol.ts --- .../src/lib/services/graphql/subscription-protocol.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts b/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts index 61ee2410..04441eee 100644 --- a/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts +++ b/packages/javascript-api/src/lib/services/graphql/subscription-protocol.ts @@ -46,10 +46,6 @@ export interface SubscriptionProtocol { serializeSubscribe(id: string, query: string): string; serializeUnsubscribe(id: string): string; serializePing(): string; - /** - * A `pong` answering a `ping` from the server. The `graphql-transport-ws` - * protocol requires the receiving party to answer a ping as soon as possible. - */ serializePong(): string; parseIncomingMessage(data: string): IncomingSubscriptionMessage; }