Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/feat-websocket-health-monitoring.md
Original file line number Diff line number Diff line change
@@ -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.
107 changes: 107 additions & 0 deletions packages/api-graphql/__tests__/WebSocketHealthMonitoring.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ConnectionState,
PubSubContentObserver,
} from '../../types/PubSub';
import type { WebSocketHealthState } from '../../types';
import {
AMPLIFY_SYMBOL,
CONNECTION_INIT_TIMEOUT,
Expand Down Expand Up @@ -1025,4 +1026,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;
}
}
7 changes: 7 additions & 0 deletions packages/api-graphql/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,3 +522,10 @@ export interface AuthModeParams extends Record<string, unknown> {
export type GenerateServerClientParams = {
config: ResourcesConfig;
} & CommonPublicClientOptions;

export interface WebSocketHealthState {
isHealthy: boolean;
connectionState: import('./PubSub').ConnectionState;
lastKeepAliveTime: number;
timeSinceLastKeepAlive: number;
}