From 7bd6f8a3ee41521ddc8509f9340c010fd8dc638e Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 27 Oct 2026 20:19:49 +0600 Subject: [PATCH] [FSSDK-13343] Skip the default browser event store when localStorage is unavailable The browser batch event processor now checks whether localStorage can be accessed before creating its default LocalStorageCache-backed EventStore. If it can't (SSR, storage blocked by browser settings or a sandboxed iframe), the processor runs without an event store and logs a startup warning. A user-provided eventStore is used as before. getBatchEventProcessor accepts startupLogs from platform factories and prepends them to its own. Co-Authored-By: Claude Opus 5.5 --- .../event_processor_factory.browser.spec.ts | 74 ++++++++++++++++++- .../event_processor_factory.browser.ts | 51 +++++++++---- .../event_processor_factory.spec.ts | 30 ++++++++ .../event_processor_factory.ts | 3 +- lib/message/error_message.ts | 2 + lib/utils/env/browser.spec.ts | 66 +++++++++++++++++ lib/utils/env/browser.ts | 32 ++++++++ 7 files changed, 242 insertions(+), 16 deletions(-) create mode 100644 lib/utils/env/browser.spec.ts create mode 100644 lib/utils/env/browser.ts diff --git a/lib/event_processor/event_processor_factory.browser.spec.ts b/lib/event_processor/event_processor_factory.browser.spec.ts index 61c6c483c..01c035854 100644 --- a/lib/event_processor/event_processor_factory.browser.spec.ts +++ b/lib/event_processor/event_processor_factory.browser.spec.ts @@ -55,6 +55,13 @@ vi.mock('../utils/cache/local_storage_cache.browser', () => { return { LocalStorageCache: vi.fn() }; }); +vi.mock('../utils/env/browser', () => { + return { + isLocalStorageAvailable: vi.fn().mockReturnValue(true), + __platforms: ['browser'], + }; +}); + vi.mock('./event_store', () => { return { DEFAULT_MAX_EVENTS_IN_STORE: 500, @@ -88,11 +95,15 @@ vi.mock('../utils/cache/store', () => { import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser'; -import { SyncPrefixStore } from '../utils/cache/store'; +import { Store, SyncPrefixStore } from '../utils/cache/store'; +import { EventWithId } from './batch_event_processor'; +import { isLocalStorageAvailable } from '../utils/env/browser'; import { default as browserDefaultEventDispatcher, default as defaultEventDispatcher } from './event_dispatcher/default_dispatcher.browser'; import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser'; -import { extractEventProcessor, FAILED_EVENT_RETRY_INTERVAL, getForwardingEventProcessor, getOpaqueBatchEventProcessor } from './event_processor_factory'; +import { extractEventProcessor, FAILED_EVENT_RETRY_INTERVAL, getForwardingEventProcessor, getOpaqueBatchEventProcessor, getPrefixEventStore } from './event_processor_factory'; import { createBatchEventProcessor, createForwardingEventProcessor } from './event_processor_factory.browser'; +import { LogLevel } from '../logging/logger'; +import { EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE } from 'error_message'; import { EventStore } from './event_store'; describe('createForwardingEventProcessor', () => { @@ -126,12 +137,71 @@ describe('createBatchEventProcessor', () => { const MockLocalStorageCache = vi.mocked(LocalStorageCache); const MockSyncPrefixStore = vi.mocked(SyncPrefixStore); const MockEventStore = vi.mocked(EventStore); + const mockIsLocalStorageAvailable = vi.mocked(isLocalStorageAvailable); + const mockGetPrefixEventStore = vi.mocked(getPrefixEventStore); beforeEach(() => { mockGetOpaqueBatchEventProcessor.mockClear(); MockLocalStorageCache.mockClear(); MockSyncPrefixStore.mockClear(); MockEventStore.mockClear(); + mockIsLocalStorageAvailable.mockClear(); + mockIsLocalStorageAvailable.mockReturnValue(true); + mockGetPrefixEventStore.mockClear(); + }); + + it('does not use any eventStore and adds a warning startup log if no eventStore is provided and localStorage is not available', () => { + mockIsLocalStorageAvailable.mockReturnValue(false); + + const processor = createBatchEventProcessor({ storeTtl: 60_000 }); + + expect(Object.is(processor, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true); + expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(undefined); + expect(MockEventStore).not.toHaveBeenCalled(); + expect(MockLocalStorageCache).not.toHaveBeenCalled(); + + expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([{ + level: LogLevel.Warn, + message: EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE, + params: [], + }]); + }); + + it('does not add any startup log if localStorage is available', () => { + mockIsLocalStorageAvailable.mockReturnValue(true); + + createBatchEventProcessor({}); + expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([]); + }); + + it('does not add any startup log if an eventStore is provided and localStorage is not available', () => { + mockIsLocalStorageAvailable.mockReturnValue(false); + + createBatchEventProcessor({ eventStore: { operation: 'sync' } as Store }); + expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].startupLogs).toEqual([]); + }); + + it('uses the provided eventStore wrapped with getPrefixEventStore regardless of localStorage availability', () => { + const providedStore = { operation: 'sync' } as Store; + const prefixStore = {} as Store; + mockGetPrefixEventStore.mockReturnValue(prefixStore); + + mockIsLocalStorageAvailable.mockReturnValue(true); + const processor1 = createBatchEventProcessor({ eventStore: providedStore }); + + expect(Object.is(processor1, mockGetOpaqueBatchEventProcessor.mock.results[0].value)).toBe(true); + expect(mockGetPrefixEventStore).toHaveBeenNthCalledWith(1, providedStore); + expect(mockGetOpaqueBatchEventProcessor.mock.calls[0][0].eventStore).toBe(prefixStore); + + mockIsLocalStorageAvailable.mockReturnValue(false); + const processor2 = createBatchEventProcessor({ eventStore: providedStore }); + + expect(Object.is(processor2, mockGetOpaqueBatchEventProcessor.mock.results[1].value)).toBe(true); + expect(mockGetPrefixEventStore).toHaveBeenNthCalledWith(2, providedStore); + expect(mockGetOpaqueBatchEventProcessor.mock.calls[1][0].eventStore).toBe(prefixStore); + + expect(MockEventStore).not.toHaveBeenCalled(); + expect(MockLocalStorageCache).not.toHaveBeenCalled(); }); it('uses an EventStore instance with AsyncStorageCache and correct options if no eventStore is provided', () => { diff --git a/lib/event_processor/event_processor_factory.browser.ts b/lib/event_processor/event_processor_factory.browser.ts index e55436236..5aa7278a1 100644 --- a/lib/event_processor/event_processor_factory.browser.ts +++ b/lib/event_processor/event_processor_factory.browser.ts @@ -13,22 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE } from 'error_message'; +import { LogLevel } from '../logging/logger'; +import { Platform } from '../platform_support'; +import { StartupLog } from '../service'; +import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser'; +import { Store } from '../utils/cache/store'; +import { isLocalStorageAvailable } from '../utils/env/browser'; +import { EventWithId } from './batch_event_processor'; +import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser'; import { EventDispatcher } from './event_dispatcher/event_dispatcher'; -import { EventProcessor } from './event_processor'; +import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser'; import { - getOpaqueBatchEventProcessor, BatchEventProcessorOptions, - OpaqueEventProcessor, - wrapEventProcessor, + FAILED_EVENT_RETRY_INTERVAL, getForwardingEventProcessor, + getOpaqueBatchEventProcessor, getPrefixEventStore, + OpaqueEventProcessor, + wrapEventProcessor, } from './event_processor_factory'; -import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser'; -import sendBeaconEventDispatcher from './event_dispatcher/send_beacon_dispatcher.browser'; -import { LocalStorageCache } from '../utils/cache/local_storage_cache.browser'; -import { FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory'; import { DEFAULT_MAX_EVENTS_IN_STORE, EventStore } from './event_store'; -import { Platform } from '../platform_support'; + export const DEFAULT_EVENT_BATCH_SIZE = 10; export const DEFAULT_EVENT_FLUSH_INTERVAL = 1_000; export const EVENT_MAX_RETRIES_BROWSER = 2; @@ -39,15 +45,33 @@ export const createForwardingEventProcessor = ( return wrapEventProcessor(getForwardingEventProcessor(eventDispatcher)); }; -export const createBatchEventProcessor = ( - options: BatchEventProcessorOptions = {} -): OpaqueEventProcessor => { - const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : new EventStore({ +const getDefaultEventStore = ( + options: BatchEventProcessorOptions, + startupLogs: StartupLog[], +): Store | undefined => { + if (!isLocalStorageAvailable()) { + startupLogs.push({ + level: LogLevel.Warn, + message: EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE, + params: [], + }); + return undefined; + } + + return new EventStore({ store: new LocalStorageCache(), maxSize: options.batchSize ? Math.max(options.batchSize * 2, DEFAULT_MAX_EVENTS_IN_STORE) : DEFAULT_MAX_EVENTS_IN_STORE, ttl: options.storeTtl, }); +}; + +export const createBatchEventProcessor = ( + options: BatchEventProcessorOptions = {} +): OpaqueEventProcessor => { + const startupLogs: StartupLog[] = []; + const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : + getDefaultEventStore(options, startupLogs); return getOpaqueBatchEventProcessor({ eventDispatcher: options.eventDispatcher || defaultEventDispatcher, @@ -63,6 +87,7 @@ export const createBatchEventProcessor = ( failedEventRetryInterval: FAILED_EVENT_RETRY_INTERVAL, eventStore, storeTtl: options.storeTtl, + startupLogs, }); }; diff --git a/lib/event_processor/event_processor_factory.spec.ts b/lib/event_processor/event_processor_factory.spec.ts index d7105fa75..73ec65870 100644 --- a/lib/event_processor/event_processor_factory.spec.ts +++ b/lib/event_processor/event_processor_factory.spec.ts @@ -352,6 +352,36 @@ describe('getBatchEventProcessor', () => { }])); }); + it('includes the provided startupLogs along with its own startup logs', () => { + const providedLog = { + level: LogLevel.Warn, + message: 'provided startup log %s', + params: ['param'], + }; + + const options = { + eventDispatcher: getMockEventDispatcher(), + defaultBatchSize: 77, + defaultFlushInterval: 12345, + startupLogs: [providedLog], + }; + + const processor = getBatchEventProcessor(options); + + expect(Object.is(processor, MockBatchEventProcessor.mock.instances[0])).toBe(true); + + const startupLogs = MockBatchEventProcessor.mock.calls[0][0].startupLogs; + expect(startupLogs).toEqual(expect.arrayContaining([ + providedLog, + { + level: LogLevel.Warn, + message: 'Invalid batchSize %s, defaulting to %s', + params: [undefined, 77], + }, + ])); + expect(options.startupLogs).toEqual([providedLog]); + }); + it('does not use a failedEventRepeater if failedEventRetryInterval is not provided', () => { const options = { eventDispatcher: getMockEventDispatcher(), diff --git a/lib/event_processor/event_processor_factory.ts b/lib/event_processor/event_processor_factory.ts index 77d54326b..14b07f2af 100644 --- a/lib/event_processor/event_processor_factory.ts +++ b/lib/event_processor/event_processor_factory.ts @@ -77,6 +77,7 @@ export type BatchEventProcessorFactoryOptions = Omit { @@ -109,7 +110,7 @@ export const getBatchEventProcessor = ( } } : undefined; - const startupLogs: StartupLog[] = []; + const startupLogs: StartupLog[] = [...(options.startupLogs ?? [])]; const { defaultFlushInterval, defaultBatchSize } = options; diff --git a/lib/message/error_message.ts b/lib/message/error_message.ts index 20433e153..b9790a671 100644 --- a/lib/message/error_message.ts +++ b/lib/message/error_message.ts @@ -102,6 +102,8 @@ export const PROMISE_NOT_ALLOWED = "Promise value is not allowed in sync operati export const SERVICE_NOT_RUNNING = "%s not running"; export const EVENT_STORE_FULL = 'Event store is full. Not saving event with id %d.'; export const LOCAL_HOLDOUT_MISSING_INCLUDED_RULES = 'Local holdout "%s" is missing or has empty "includedRules"; skipping.'; +export const EVENT_STORE_LOCAL_STORAGE_NOT_AVAILABLE = + 'localStorage is not available, events will not be persisted.'; export const messages: string[] = []; diff --git a/lib/utils/env/browser.spec.ts b/lib/utils/env/browser.spec.ts new file mode 100644 index 000000000..85fc4e5aa --- /dev/null +++ b/lib/utils/env/browser.spec.ts @@ -0,0 +1,66 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { it, describe, expect, beforeEach, afterEach } from 'vitest'; +import { isLocalStorageAvailable } from './browser'; + +describe('isLocalStorageAvailable', () => { + let originalDescriptor: PropertyDescriptor | undefined; + + const mockLocalStorageGetter = (get: () => unknown) => { + Object.defineProperty(globalThis, 'localStorage', { get, configurable: true }); + }; + + beforeEach(() => { + originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + }); + + afterEach(() => { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'localStorage', originalDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'localStorage'); + } + }); + + it('should return true when localStorage global can be accessed', () => { + mockLocalStorageGetter(() => ({})); + + expect(isLocalStorageAvailable()).toBe(true); + }); + + it('should return false when localStorage is null', () => { + mockLocalStorageGetter(() => null); + + expect(isLocalStorageAvailable()).toBe(false); + }); + + it('should return false when localStorage is not defined', () => { + mockLocalStorageGetter(() => { + throw new ReferenceError('localStorage is not defined'); + }); + + expect(isLocalStorageAvailable()).toBe(false); + }); + + it('should return false when accessing localStorage throws a SecurityError', () => { + mockLocalStorageGetter(() => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }); + + expect(isLocalStorageAvailable()).toBe(false); + }); +}); diff --git a/lib/utils/env/browser.ts b/lib/utils/env/browser.ts new file mode 100644 index 000000000..dd1048111 --- /dev/null +++ b/lib/utils/env/browser.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Platform } from '../../platform_support'; + +/** + * Checks whether localStorage exists and is accessible. Referencing it throws a + * ReferenceError where it is not defined (e.g. SSR) and a SecurityError where + * storage is blocked (e.g. cookies disabled, sandboxed iframes). + */ +export const isLocalStorageAvailable = (): boolean => { + try { + return !!localStorage; + } catch { + return false; + } +}; + +export const __platforms: Platform[] = ['browser'];