Skip to content

Commit 9c832cd

Browse files
committed
feat(api-graphql): add WebSocket health monitoring and manual reconnection
- Add getConnectionHealth() method for current connection state - Add getPersistentConnectionHealth() for cross-session health tracking - Add isConnected() for quick connection status check - Add reconnect() for manual WebSocket reconnection - Implement persistent keep-alive tracking using AsyncStorage/localStorage - Use 65-second threshold for health monitoring - Track keep-alive timestamps for connection health assessment Fixes #9749, #4459, #5403, #7057
1 parent 5bf5685 commit 9c832cd

File tree

2 files changed

+148
-0
lines changed

2 files changed

+148
-0
lines changed

packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Observable, Subscription, SubscriptionLike } from 'rxjs';
44
import { GraphQLError } from 'graphql';
55
import { ConsoleLogger, Hub, HubPayload } from '@aws-amplify/core';
6+
import type { KeyValueStorageInterface } from '@aws-amplify/core';
67
import {
78
CustomUserAgentDetails,
89
DocumentType,
@@ -18,6 +19,7 @@ import {
1819
ConnectionState,
1920
PubSubContentObserver,
2021
} from '../../types/PubSub';
22+
import type { WebSocketHealthState } from '../../types';
2123
import {
2224
AMPLIFY_SYMBOL,
2325
CONNECTION_INIT_TIMEOUT,
@@ -49,6 +51,35 @@ import {
4951
} from './appsyncUrl';
5052
import { awsRealTimeHeaderBasedAuth } from './authHeaders';
5153

54+
// Storage key for persistent keep-alive tracking
55+
const KEEP_ALIVE_STORAGE_KEY = 'AWS_AMPLIFY_LAST_KEEP_ALIVE';
56+
57+
// Platform-safe storage implementation
58+
let platformStorage: Pick<
59+
KeyValueStorageInterface,
60+
'setItem' | 'getItem'
61+
> | null = null;
62+
63+
try {
64+
// Try to import AsyncStorage for React Native (optional dependency)
65+
const AsyncStorage =
66+
// eslint-disable-next-line import/no-extraneous-dependencies
67+
require('@react-native-async-storage/async-storage').default;
68+
platformStorage = AsyncStorage;
69+
} catch (e) {
70+
// Fallback for web/other platforms - use localStorage if available
71+
if (typeof localStorage !== 'undefined') {
72+
platformStorage = {
73+
setItem: (key: string, value: string) => {
74+
localStorage.setItem(key, value);
75+
76+
return Promise.resolve();
77+
},
78+
getItem: (key: string) => Promise.resolve(localStorage.getItem(key)),
79+
};
80+
}
81+
}
82+
5283
const dispatchApiEvent = (payload: HubPayload) => {
5384
Hub.dispatch('api', payload, 'PubSub', AMPLIFY_SYMBOL);
5485
};
@@ -106,6 +137,7 @@ export abstract class AWSWebSocketProvider {
106137
/**
107138
* Mark the socket closed and release all active listeners
108139
*/
140+
109141
close() {
110142
// Mark the socket closed both in status and the connection monitor
111143
this.socketStatus = SOCKET_STATUS.CLOSED;
@@ -681,6 +713,15 @@ export abstract class AWSWebSocketProvider {
681713
if (type === MESSAGE_TYPES.GQL_CONNECTION_KEEP_ALIVE) {
682714
this.maintainKeepAlive();
683715

716+
// Persist keep-alive timestamp for cross-session tracking
717+
if (platformStorage) {
718+
platformStorage
719+
.setItem(KEEP_ALIVE_STORAGE_KEY, `${Date.now()}`)
720+
.catch(error => {
721+
this.logger.warn('Failed to persist keep-alive timestamp:', error);
722+
});
723+
}
724+
684725
return;
685726
}
686727

@@ -1025,4 +1066,92 @@ export abstract class AWSWebSocketProvider {
10251066
}
10261067
}
10271068
};
1069+
1070+
// WebSocket Health & Control API
1071+
1072+
/**
1073+
* Get current WebSocket health state
1074+
*/
1075+
getConnectionHealth(): WebSocketHealthState {
1076+
const timeSinceLastKeepAlive = Date.now() - this.keepAliveTimestamp;
1077+
1078+
const isHealthy =
1079+
this.connectionState === ConnectionState.Connected &&
1080+
timeSinceLastKeepAlive < DEFAULT_KEEP_ALIVE_ALERT_TIMEOUT;
1081+
1082+
return {
1083+
isHealthy,
1084+
connectionState: this.connectionState || ConnectionState.Disconnected,
1085+
lastKeepAliveTime: this.keepAliveTimestamp,
1086+
timeSinceLastKeepAlive,
1087+
};
1088+
}
1089+
1090+
/**
1091+
* Get persistent WebSocket health state (survives app restarts)
1092+
*/
1093+
async getPersistentConnectionHealth(): Promise<WebSocketHealthState> {
1094+
let persistentKeepAliveTime = 0;
1095+
1096+
// Try to get persistent keep-alive timestamp
1097+
if (platformStorage) {
1098+
try {
1099+
const persistentKeepAlive = await platformStorage.getItem(
1100+
KEEP_ALIVE_STORAGE_KEY,
1101+
);
1102+
if (persistentKeepAlive) {
1103+
persistentKeepAliveTime = Number(persistentKeepAlive) || 0;
1104+
}
1105+
} catch (error) {
1106+
this.logger.warn(
1107+
'Failed to retrieve persistent keep-alive timestamp:',
1108+
error,
1109+
);
1110+
}
1111+
}
1112+
1113+
// Use the more recent timestamp (in-memory vs persistent)
1114+
const lastKeepAliveTime = Math.max(
1115+
this.keepAliveTimestamp,
1116+
persistentKeepAliveTime,
1117+
);
1118+
1119+
const timeSinceLastKeepAlive =
1120+
lastKeepAliveTime > 0 ? Date.now() - lastKeepAliveTime : Infinity; // If no keep-alive has been received, treat as unhealthy
1121+
1122+
// Health check includes persistent data
1123+
const isHealthy =
1124+
this.connectionState === ConnectionState.Connected &&
1125+
timeSinceLastKeepAlive < DEFAULT_KEEP_ALIVE_ALERT_TIMEOUT;
1126+
1127+
return {
1128+
isHealthy,
1129+
connectionState: this.connectionState || ConnectionState.Disconnected,
1130+
lastKeepAliveTime: lastKeepAliveTime > 0 ? lastKeepAliveTime : undefined,
1131+
timeSinceLastKeepAlive:
1132+
lastKeepAliveTime > 0 ? timeSinceLastKeepAlive : undefined,
1133+
};
1134+
}
1135+
1136+
/**
1137+
* Check if WebSocket is currently connected
1138+
*/
1139+
isConnected(): boolean {
1140+
return this.awsRealTimeSocket?.readyState === WebSocket.OPEN;
1141+
}
1142+
1143+
/**
1144+
* Manually reconnect WebSocket
1145+
*/
1146+
async reconnect(): Promise<void> {
1147+
this.logger.info('Manual WebSocket reconnection requested');
1148+
1149+
// Close existing connection if any
1150+
if (this.isConnected()) {
1151+
await this.close();
1152+
}
1153+
1154+
// Trigger reconnection through the reconnection monitor
1155+
this.reconnectionMonitor.record(ReconnectEvent.START_RECONNECT);
1156+
}
10281157
}

packages/api-graphql/src/types/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,3 +522,22 @@ export interface AuthModeParams extends Record<string, unknown> {
522522
export type GenerateServerClientParams = {
523523
config: ResourcesConfig;
524524
} & CommonPublicClientOptions;
525+
526+
// WebSocket health and control types
527+
export interface WebSocketHealthState {
528+
isHealthy: boolean;
529+
connectionState: import('./PubSub').ConnectionState;
530+
lastKeepAliveTime?: number;
531+
timeSinceLastKeepAlive?: number;
532+
}
533+
534+
export interface WebSocketControl {
535+
reconnect(): Promise<void>;
536+
disconnect(): void;
537+
isConnected(): boolean;
538+
getConnectionHealth(): WebSocketHealthState;
539+
getPersistentConnectionHealth(): Promise<WebSocketHealthState>;
540+
onConnectionStateChange(
541+
callback: (state: import('./PubSub').ConnectionState) => void,
542+
): () => void;
543+
}

0 commit comments

Comments
 (0)