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
2 changes: 1 addition & 1 deletion packages/api-graphql/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ class FakeWebSocket implements WebSocket {
url!: string;
close(code?: number, reason?: string): void {
const closeResolver = this.closeResolverFcn();
if (closeResolver) closeResolver(Promise.resolve(undefined));
if (closeResolver) closeResolver(undefined as any);

try {
this.onclose(new CloseEvent('', {}));
Expand Down
144 changes: 144 additions & 0 deletions packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ import {
} from './appsyncUrl';
import { awsRealTimeHeaderBasedAuth } from './authHeaders';

// Platform-safe AsyncStorage import
let AsyncStorage: any;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have the KeyValueStorageInterface.

It can be (partially) used as a type here. and would be better than any

try {
// Try to import AsyncStorage for React Native (optional dependency)
// eslint-disable-next-line import/no-extraneous-dependencies
AsyncStorage = require('@react-native-async-storage/async-storage').default;
} catch (e) {
// Fallback for web/other platforms - use localStorage if available
AsyncStorage =
typeof localStorage !== 'undefined'
? {
setItem: (key: string, value: string) => {
localStorage.setItem(key, value);

return Promise.resolve();
},
getItem: (key: string) => Promise.resolve(localStorage.getItem(key)),
}
: null;
}

const dispatchApiEvent = (payload: HubPayload) => {
Hub.dispatch('api', payload, 'PubSub', AMPLIFY_SYMBOL);
};
Expand Down Expand Up @@ -106,6 +127,7 @@ export abstract class AWSWebSocketProvider {
/**
* Mark the socket closed and release all active listeners
*/

close() {
// Mark the socket closed both in status and the connection monitor
this.socketStatus = SOCKET_STATUS.CLOSED;
Expand Down Expand Up @@ -681,6 +703,18 @@ export abstract class AWSWebSocketProvider {
if (type === MESSAGE_TYPES.GQL_CONNECTION_KEEP_ALIVE) {
this.maintainKeepAlive();

// Persist keep-alive timestamp for cross-session tracking
if (AsyncStorage) {
try {
AsyncStorage.setItem(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not handling the returned Promise.

and as such the catch-path won't be taken.

use .catch or async/await instead

'AWS_AMPLIFY_LAST_KEEP_ALIVE',
JSON.stringify(new Date()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better use Date.now() it returns a number, and can be used in a template like such:

AsyncStorage.setItem('AWS_AMPLIFY_LAST_KEEP_ALIVE', `${Date.now()}`)

);
} catch (error) {
this.logger.warn('Failed to persist keep-alive timestamp:', error);
}
}

return;
}

Expand Down Expand Up @@ -1025,4 +1059,114 @@ export abstract class AWSWebSocketProvider {
}
}
};

// WebSocket Health & Control API

/**
* Get current WebSocket health state
*/
getConnectionHealth(): import('../../types').WebSocketHealthState {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better use an import statement at the top of the file and use the type then directly

const timeSinceLastKeepAlive = this.keepAliveTimestamp
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.keepAliveTimestamp is always defined and always a number.
the tertiary is unnecessary

? Date.now() - this.keepAliveTimestamp
: undefined;

const isHealthy =
this.connectionState === ConnectionState.Connected &&
this.keepAliveTimestamp &&
timeSinceLastKeepAlive !== undefined &&
timeSinceLastKeepAlive < 65000; // 65 second threshold
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be shortened:

const isHealthy =
			this.connectionState === ConnectionState.Connected &&
			timeSinceLastKeepAlive < 65000; // 65 second threshold


return {
isHealthy: Boolean(isHealthy),
connectionState: this.connectionState || ConnectionState.Disconnected,
lastKeepAliveTime: this.keepAliveTimestamp,
timeSinceLastKeepAlive,
};
}

/**
* Get persistent WebSocket health state (survives app restarts)
*/
async getPersistentConnectionHealth(): Promise<
import('../../types').WebSocketHealthState
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would be resolved using direct import

> {
let persistentKeepAliveTime: number | undefined;
let _timeSinceLastPersistentKeepAlive: number | undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the use of the _.

there is no need to mark variables this way and it can just be timeSinceLastPersistentKeepAlive


// Try to get persistent keep-alive timestamp
if (AsyncStorage) {
try {
const persistentKeepAlive = await AsyncStorage.getItem(
'AWS_AMPLIFY_LAST_KEEP_ALIVE',
);
if (persistentKeepAlive) {
const keepAliveDate = new Date(JSON.parse(persistentKeepAlive));
persistentKeepAliveTime = keepAliveDate.getTime();
_timeSinceLastPersistentKeepAlive =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if using Date.now() already for storing, timeSinceLastPersistentKeepAlive becomes trivial

timeSinceLastPersistentKeepAlive = Date.now() - persistentKeepAlive

Date.now() - persistentKeepAliveTime;
}
} catch (error) {
this.logger.warn(
'Failed to retrieve persistent keep-alive timestamp:',
error,
);
}
}

// Use the more recent timestamp (in-memory vs persistent)
const lastKeepAliveTime =
Math.max(this.keepAliveTimestamp || 0, persistentKeepAliveTime || 0) ||
undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the undefined?
why not have lastKeepAliveTime be 0?

this simplifies timeSinceLastKeepAlive and isHealthy, too


const timeSinceLastKeepAlive = lastKeepAliveTime
? Date.now() - lastKeepAliveTime
: undefined;

// Health check includes persistent data
const isHealthy =
this.connectionState === ConnectionState.Connected &&
lastKeepAliveTime &&
timeSinceLastKeepAlive !== undefined &&
timeSinceLastKeepAlive < 65000; // 65 second threshold

return {
isHealthy: Boolean(isHealthy),
connectionState: this.connectionState || ConnectionState.Disconnected,
lastKeepAliveTime,
timeSinceLastKeepAlive,
};
}

/**
* Check if WebSocket is currently connected
*/
isConnected(): boolean {
return this.awsRealTimeSocket?.readyState === WebSocket.OPEN;
}

/**
* Manually reconnect WebSocket
*/
async reconnect(): Promise<void> {
this.logger.info('Manual WebSocket reconnection requested');

// Close existing connection if any
if (this.isConnected()) {
this.close();
// Wait briefly for clean disconnect
await new Promise(resolve => setTimeout(resolve, 100));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just await this.close, then you won't need this Promise

}

// Reconnect - this would need to be implemented based on how the provider is used
// For now, log that reconnection was attempted
this.logger.info('WebSocket reconnection attempted');
}

/**
* Manually disconnect WebSocket
*/
disconnect(): void {
this.logger.info('Manual WebSocket disconnect requested');
this.close();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not directly use close?

}
}
19 changes: 19 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,22 @@ export interface AuthModeParams extends Record<string, unknown> {
export type GenerateServerClientParams = {
config: ResourcesConfig;
} & CommonPublicClientOptions;

// WebSocket health and control types
export interface WebSocketHealthState {
isHealthy: boolean;
connectionState: import('./PubSub').ConnectionState;
lastKeepAliveTime?: number;
timeSinceLastKeepAlive?: number;
}

export interface WebSocketControl {
reconnect(): Promise<void>;
disconnect(): void;
isConnected(): boolean;
getConnectionHealth(): WebSocketHealthState;
getPersistentConnectionHealth(): Promise<WebSocketHealthState>;
onConnectionStateChange(
callback: (state: import('./PubSub').ConnectionState) => void,
): () => void;
}