diff --git a/packages/api-graphql/__tests__/AWSAppSyncEventProvider.test.ts b/packages/api-graphql/__tests__/AWSAppSyncEventProvider.test.ts index d84f1daad89..a8c29a63e87 100644 --- a/packages/api-graphql/__tests__/AWSAppSyncEventProvider.test.ts +++ b/packages/api-graphql/__tests__/AWSAppSyncEventProvider.test.ts @@ -171,6 +171,77 @@ describe('AppSyncEventProvider', () => { 'Connection failed: Retriable Test', ); }); + + test('subscription observer is triggered when a connection is formed and a data message is received after connection ack', async () => { + expect.assertions(1); + const mockNext = jest.fn(); + + const observer = provider.subscribe({ + appSyncGraphqlEndpoint: 'ws://localhost:8080', + }); + + const event = JSON.stringify({ some: 'data' }); + + observer.subscribe({ + // Succeed only when the first message comes through + next: mockNext, + // Closing a hot connection (for cleanup) makes it blow up the test stack + error: () => {}, + }); + await fakeWebSocketInterface?.standardConnectionHandshake(); + await fakeWebSocketInterface?.startAckMessage({ + connectionTimeoutMs: 100, + }); + await fakeWebSocketInterface?.sendDataMessage({ + id: fakeWebSocketInterface?.webSocket.subscriptionId, + type: MESSAGE_TYPES.DATA, + event, + }); + + // events callback returns entire message contents + expect(mockNext).toHaveBeenCalledWith({ + id: fakeWebSocketInterface?.webSocket.subscriptionId, + type: MESSAGE_TYPES.DATA, + event: JSON.parse(event), + }); + }); + + test('socket is disconnected after .close() is called', async () => { + expect.assertions(2); + const mockNext = jest.fn(); + + const observer = provider.subscribe({ + appSyncGraphqlEndpoint: 'ws://localhost:8080', + }); + + const event = JSON.stringify({ some: 'data' }); + + observer.subscribe({ + next: mockNext, + error: () => {}, + }); + + await fakeWebSocketInterface?.standardConnectionHandshake(); + await fakeWebSocketInterface?.startAckMessage({ + connectionTimeoutMs: 100, + }); + await fakeWebSocketInterface?.sendDataMessage({ + id: fakeWebSocketInterface?.webSocket.subscriptionId, + type: MESSAGE_TYPES.DATA, + event, + }); + + // events callback returns entire message contents + expect(mockNext).toHaveBeenCalledWith({ + id: fakeWebSocketInterface?.webSocket.subscriptionId, + type: MESSAGE_TYPES.DATA, + event: JSON.parse(event), + }); + + await provider.close(); + + expect(fakeWebSocketInterface.hasClosed).resolves.toBeUndefined(); + }); }); }); }); diff --git a/packages/api-graphql/__tests__/helpers.ts b/packages/api-graphql/__tests__/helpers.ts index ac26232dd90..424fc4e3129 100644 --- a/packages/api-graphql/__tests__/helpers.ts +++ b/packages/api-graphql/__tests__/helpers.ts @@ -258,9 +258,8 @@ export class FakeWebSocketInterface { /** * Run a command and resolve to allow internal behavior to execute */ - async runAndResolve(fn) { + async runAndResolve(fn: Function) { await fn(); - await Promise.resolve(); } /** @@ -310,6 +309,10 @@ class FakeWebSocket implements WebSocket { close(code?: number, reason?: string): void { const closeResolver = this.closeResolverFcn(); if (closeResolver) closeResolver(Promise.resolve(undefined)); + + try { + this.onclose(new CloseEvent('', {})); + } catch {} } send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { const parsedInput = JSON.parse(String(data)); diff --git a/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts b/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts index 5bfeddd89fa..214de3b92f4 100644 --- a/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts +++ b/packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts @@ -43,10 +43,15 @@ interface DataResponse { const PROVIDER_NAME = 'AWSAppSyncEventsProvider'; const WS_PROTOCOL_NAME = 'aws-appsync-event-ws'; +const CONNECT_URI = ''; // events does not expect a connect uri export class AWSAppSyncEventProvider extends AWSWebSocketProvider { constructor() { - super({ providerName: PROVIDER_NAME, wsProtocolName: WS_PROTOCOL_NAME }); + super({ + providerName: PROVIDER_NAME, + wsProtocolName: WS_PROTOCOL_NAME, + connectUri: CONNECT_URI, + }); } getProviderName() { @@ -90,7 +95,6 @@ export class AWSAppSyncEventProvider extends AWSWebSocketProvider { appSyncGraphqlEndpoint, authenticationType, query, - variables, apiKey, region, } = options; @@ -100,7 +104,7 @@ export class AWSAppSyncEventProvider extends AWSWebSocketProvider { // events: [variables], // }; - const serializedData = JSON.stringify([variables]); + const serializedData = JSON.stringify({ channel: query }); const headers = { ...(await awsRealTimeHeaderBasedAuth({ @@ -167,7 +171,7 @@ export class AWSAppSyncEventProvider extends AWSWebSocketProvider { if (type === MESSAGE_TYPES.DATA && payload) { const deserializedEvent = JSON.parse(payload); if (observer) { - observer.next(deserializedEvent); + observer.next({ id, type, event: deserializedEvent }); } else { this.logger.debug(`observer not found for id: ${id}`); } diff --git a/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts b/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts index 2ba5968a890..084891b002b 100644 --- a/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts +++ b/packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts @@ -42,10 +42,15 @@ interface DataPayload { const PROVIDER_NAME = 'AWSAppSyncRealTimeProvider'; const WS_PROTOCOL_NAME = 'graphql-ws'; +const CONNECT_URI = '/connect'; export class AWSAppSyncRealTimeProvider extends AWSWebSocketProvider { constructor() { - super({ providerName: PROVIDER_NAME, wsProtocolName: WS_PROTOCOL_NAME }); + super({ + providerName: PROVIDER_NAME, + wsProtocolName: WS_PROTOCOL_NAME, + connectUri: CONNECT_URI, + }); } getProviderName() { diff --git a/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts b/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts index 3553a008123..5d710e478bc 100644 --- a/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts +++ b/packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts @@ -74,13 +74,14 @@ interface ParsedMessagePayload { interface AWSWebSocketProviderArgs { providerName: string; wsProtocolName: string; + connectUri: string; } export abstract class AWSWebSocketProvider { protected logger: ConsoleLogger; protected subscriptionObserverMap = new Map(); - private awsRealTimeSocket?: WebSocket; + protected awsRealTimeSocket?: WebSocket; private socketStatus: SOCKET_STATUS = SOCKET_STATUS.CLOSED; private keepAliveTimeoutId?: ReturnType; private keepAliveTimeout = DEFAULT_KEEP_ALIVE_TIMEOUT; @@ -91,10 +92,12 @@ export abstract class AWSWebSocketProvider { private readonly reconnectionMonitor = new ReconnectionMonitor(); private connectionStateMonitorSubscription: SubscriptionLike; private readonly wsProtocolName: string; + private readonly wsConnectUri: string; constructor(args: AWSWebSocketProviderArgs) { this.logger = new ConsoleLogger(args.providerName); this.wsProtocolName = args.wsProtocolName; + this.wsConnectUri = args.connectUri; this.connectionStateMonitorSubscription = this._startConnectionStateMonitoring(); @@ -112,6 +115,24 @@ export abstract class AWSWebSocketProvider { this.connectionStateMonitorSubscription.unsubscribe(); // Complete all reconnect observers this.reconnectionMonitor.close(); + + return new Promise((resolve, reject) => { + if (this.awsRealTimeSocket) { + this.awsRealTimeSocket.onclose = (_: CloseEvent) => { + this.subscriptionObserverMap = new Map(); + this.awsRealTimeSocket = undefined; + resolve(); + }; + + this.awsRealTimeSocket.onerror = (err: any) => { + reject(err); + }; + + this.awsRealTimeSocket.close(); + } else { + resolve(); + } + }); } subscribe( @@ -721,7 +742,7 @@ export abstract class AWSWebSocketProvider { const authHeader = await awsRealTimeHeaderBasedAuth({ authenticationType, payload: payloadString, - canonicalUri: '/connect', + canonicalUri: this.wsConnectUri, apiKey, appSyncGraphqlEndpoint, region, diff --git a/packages/api-graphql/src/internals/events/index.ts b/packages/api-graphql/src/internals/events/index.ts index 49860a92049..c1101111fd4 100644 --- a/packages/api-graphql/src/internals/events/index.ts +++ b/packages/api-graphql/src/internals/events/index.ts @@ -154,8 +154,19 @@ async function post( } } -function closeAll(): void { - eventProvider.close(); +/** + * @experimental API may change in future versions + * + * Close WebSocket connection, disconnect listeners and reconnect observers + * + * @example + * await events.closeAll() + * + * @returns void on success + * @throws on error + */ +async function closeAll(): Promise { + await eventProvider.close(); } export { connect, post, closeAll }; diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index e45fedb59ee..85b35c407d4 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -293,7 +293,7 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.41 kB" + "limit": "17.5 kB" }, { "name": "[Analytics] record (Kinesis)", @@ -317,7 +317,7 @@ "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.91 kB" + "limit": "15.95 kB" }, { "name": "[Analytics] enable", @@ -497,7 +497,7 @@ "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "20.08 kB" + "limit": "20.15 kB" } ] } diff --git a/packages/core/__tests__/storage/DefaultStorage.test.ts b/packages/core/__tests__/storage/DefaultStorage.test.ts index 47214d899d7..519adb01af9 100644 --- a/packages/core/__tests__/storage/DefaultStorage.test.ts +++ b/packages/core/__tests__/storage/DefaultStorage.test.ts @@ -1,4 +1,5 @@ import { DefaultStorage } from '../../src/storage/DefaultStorage'; +import { InMemoryStorage } from '../../src/storage/InMemoryStorage'; const key = 'k'; const value = 'value'; @@ -35,4 +36,25 @@ describe('DefaultStorage', () => { await defaultStorage.clear(); expect(defaultStorage.getItem(key)).resolves.toBeNull(); }); + + it('should fall back to alternative storage when localStorage is not accessible', async () => { + // Mock window.localStorage to throw an error + const originalLocalStorage = window.localStorage; + + Object.defineProperty(window, 'localStorage', { + value: undefined, + writable: true, + }); + + // Create a new DefaultStorage instance to trigger the fallback + const fallbackStorage = new DefaultStorage(); + + // Verify that the storage still works as expected + expect(fallbackStorage.storage instanceof InMemoryStorage).toEqual(true); + + // Restore the original localStorage + Object.defineProperty(window, 'localStorage', { + value: originalLocalStorage, + }); + }); }); diff --git a/packages/core/__tests__/storage/SessionStorage.test.ts b/packages/core/__tests__/storage/SessionStorage.test.ts index df6116d823e..81545838f12 100644 --- a/packages/core/__tests__/storage/SessionStorage.test.ts +++ b/packages/core/__tests__/storage/SessionStorage.test.ts @@ -1,9 +1,10 @@ +import { InMemoryStorage } from '../../src/storage/InMemoryStorage'; import { SessionStorage } from '../../src/storage/SessionStorage'; const key = 'k'; const value = 'value'; -describe('sessionStorage', () => { +describe('SessionStorage', () => { let sessionStorage: SessionStorage; beforeEach(() => { @@ -37,4 +38,25 @@ describe('sessionStorage', () => { await sessionStorage.clear(); expect(await sessionStorage.getItem(key)).toBeNull(); }); + + it('should fall back to alternative storage when sessionStorage is not accessible', async () => { + // Mock window.sessionStorage to throw an error + const originalSessionStorage = window.sessionStorage; + + Object.defineProperty(window, 'sessionStorage', { + value: undefined, + writable: true, + }); + + // Create a new SessionStorage instance to trigger the fallback + const fallbackStorage = new SessionStorage(); + + // Verify that the storage still works as expected + expect(fallbackStorage.storage instanceof InMemoryStorage).toEqual(true); + + // Restore the original sessionStorage + Object.defineProperty(window, 'sessionStorage', { + value: originalSessionStorage, + }); + }); }); diff --git a/packages/core/package.json b/packages/core/package.json index 1161de3d320..12d291faa59 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -106,7 +106,7 @@ "name": "Cache (default browser storage)", "path": "./dist/esm/index.mjs", "import": "{ Cache }", - "limit": "3.3 kB" + "limit": "3.4 kB" } ], "exports": { diff --git a/packages/core/src/storage/utils.ts b/packages/core/src/storage/utils.ts index 527d91d7f4e..8307f205917 100644 --- a/packages/core/src/storage/utils.ts +++ b/packages/core/src/storage/utils.ts @@ -1,22 +1,51 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { ConsoleLogger } from '../Logger'; + import { InMemoryStorage } from './InMemoryStorage'; /** * @internal * @returns Either a reference to window.localStorage or an in-memory storage as fallback */ -export const getLocalStorageWithFallback = (): Storage => - typeof window !== 'undefined' && window.localStorage - ? window.localStorage - : new InMemoryStorage(); + +const logger = new ConsoleLogger('CoreStorageUtils'); + +export const getLocalStorageWithFallback = (): Storage => { + try { + // Attempt to use localStorage directly + if (typeof window !== 'undefined' && window.localStorage) { + return window.localStorage; + } + } catch (e) { + // Handle any errors related to localStorage access + logger.error('LocalStorage access failed:', e); + } + + // Return in-memory storage as a fallback if localStorage is not accessible + return new InMemoryStorage(); +}; /** * @internal * @returns Either a reference to window.sessionStorage or an in-memory storage as fallback */ -export const getSessionStorageWithFallback = (): Storage => - typeof window !== 'undefined' && window.sessionStorage - ? window.sessionStorage - : new InMemoryStorage(); +export const getSessionStorageWithFallback = (): Storage => { + try { + // Attempt to use sessionStorage directly + if (typeof window !== 'undefined' && window.sessionStorage) { + // Verify we can actually use it by testing access + window.sessionStorage.getItem('test'); + + return window.sessionStorage; + } + + throw new Error('sessionStorage is not defined'); + } catch (e) { + // Handle any errors related to sessionStorage access + logger.error('SessionStorage access failed:', e); + + return new InMemoryStorage(); + } +};