diff --git a/.changeset/feat-websocket-health-monitoring.md b/.changeset/feat-websocket-health-monitoring.md new file mode 100644 index 00000000000..2d043aeb641 --- /dev/null +++ b/.changeset/feat-websocket-health-monitoring.md @@ -0,0 +1,8 @@ +--- +'@aws-amplify/api-graphql': patch +--- + +feat(api-graphql): add WebSocket connection health monitoring + +Add `getConnectionHealth()` and `isConnected()` methods to the WebSocket provider, +enabling consumers to check real-time connection health status and keep-alive staleness. diff --git a/packages/api-graphql/__tests__/WebSocketHealthMonitoring.test.ts b/packages/api-graphql/__tests__/WebSocketHealthMonitoring.test.ts new file mode 100644 index 00000000000..c6787515032 --- /dev/null +++ b/packages/api-graphql/__tests__/WebSocketHealthMonitoring.test.ts @@ -0,0 +1,107 @@ +import { AWSAppSyncRealTimeProvider } from '../src/Providers/AWSAppSyncRealTimeProvider'; +import { ConnectionState as CS } from '../src/types/PubSub'; + +describe('WebSocket Health Monitoring', () => { + let provider: AWSAppSyncRealTimeProvider; + + beforeEach(() => { + provider = new AWSAppSyncRealTimeProvider(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getConnectionHealth', () => { + test('returns healthy when connected with recent keep-alive', () => { + (provider as any).connectionState = CS.Connected; + (provider as any).keepAliveTimestamp = Date.now(); + + const health = provider.getConnectionHealth(); + + expect(health.isHealthy).toBe(true); + expect(health.connectionState).toBe(CS.Connected); + expect(health.lastKeepAliveTime).toBeGreaterThan(0); + expect(health.timeSinceLastKeepAlive).toBeLessThan(1000); + }); + + test('returns unhealthy when not connected', () => { + (provider as any).connectionState = CS.Disconnected; + (provider as any).keepAliveTimestamp = Date.now(); + + const health = provider.getConnectionHealth(); + + expect(health.isHealthy).toBe(false); + expect(health.connectionState).toBe(CS.Disconnected); + }); + + test('returns unhealthy when keep-alive is stale (>65s)', () => { + (provider as any).connectionState = CS.Connected; + (provider as any).keepAliveTimestamp = Date.now() - 66_000; + + const health = provider.getConnectionHealth(); + + expect(health.isHealthy).toBe(false); + expect(health.connectionState).toBe(CS.Connected); + expect(health.timeSinceLastKeepAlive).toBeGreaterThan(65_000); + }); + + test('returns unhealthy during connection disruption', () => { + (provider as any).connectionState = CS.ConnectionDisrupted; + (provider as any).keepAliveTimestamp = Date.now(); + + const health = provider.getConnectionHealth(); + + expect(health.isHealthy).toBe(false); + expect(health.connectionState).toBe(CS.ConnectionDisrupted); + }); + + test('defaults connectionState to Disconnected when undefined', () => { + (provider as any).connectionState = undefined; + + const health = provider.getConnectionHealth(); + + expect(health.connectionState).toBe(CS.Disconnected); + }); + }); + + describe('isConnected', () => { + test('returns true when WebSocket readyState is OPEN', () => { + (provider as any).awsRealTimeSocket = { + readyState: WebSocket.OPEN, + }; + + expect(provider.isConnected()).toBe(true); + }); + + test('returns false when WebSocket is undefined', () => { + (provider as any).awsRealTimeSocket = undefined; + + expect(provider.isConnected()).toBe(false); + }); + + test('returns false when WebSocket is CONNECTING', () => { + (provider as any).awsRealTimeSocket = { + readyState: WebSocket.CONNECTING, + }; + + expect(provider.isConnected()).toBe(false); + }); + + test('returns false when WebSocket is CLOSED', () => { + (provider as any).awsRealTimeSocket = { + readyState: WebSocket.CLOSED, + }; + + expect(provider.isConnected()).toBe(false); + }); + + test('returns false when WebSocket is CLOSING', () => { + (provider as any).awsRealTimeSocket = { + readyState: WebSocket.CLOSING, + }; + + expect(provider.isConnected()).toBe(false); + }); + }); +}); diff --git a/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts b/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts index ad1eda1e1cb..f0d12bec204 100644 --- a/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts +++ b/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts @@ -18,6 +18,7 @@ import { ConnectionState, PubSubContentObserver, } from '../../types/PubSub'; +import type { WebSocketHealthState } from '../../types'; import { AMPLIFY_SYMBOL, CONNECTION_INIT_TIMEOUT, @@ -1058,4 +1059,28 @@ export abstract class AWSWebSocketProvider { } } }; + + /** + * Get current WebSocket health state + */ + getConnectionHealth(): WebSocketHealthState { + const timeSinceLastKeepAlive = Date.now() - this.keepAliveTimestamp; + const isHealthy = + this.connectionState === ConnectionState.Connected && + timeSinceLastKeepAlive < DEFAULT_KEEP_ALIVE_ALERT_TIMEOUT; + + return { + isHealthy, + connectionState: this.connectionState || ConnectionState.Disconnected, + lastKeepAliveTime: this.keepAliveTimestamp, + timeSinceLastKeepAlive, + }; + } + + /** + * Check if WebSocket is currently connected + */ + isConnected(): boolean { + return this.awsRealTimeSocket?.readyState === WebSocket.OPEN; + } } diff --git a/packages/api-graphql/src/types/index.ts b/packages/api-graphql/src/types/index.ts index 2fa4ce08800..47b3cfcccd4 100644 --- a/packages/api-graphql/src/types/index.ts +++ b/packages/api-graphql/src/types/index.ts @@ -522,3 +522,10 @@ export interface AuthModeParams extends Record { export type GenerateServerClientParams = { config: ResourcesConfig; } & CommonPublicClientOptions; + +export interface WebSocketHealthState { + isHealthy: boolean; + connectionState: import('./PubSub').ConnectionState; + lastKeepAliveTime: number; + timeSinceLastKeepAlive: number; +}