From 1b95946339d32a0395ab6bf56e37ecbd6eb832ae Mon Sep 17 00:00:00 2001 From: Oscar Bazaldua <511911+oscb@users.noreply.github.com> Date: Wed, 23 Aug 2023 14:01:30 -0700 Subject: [PATCH] feat: storage option to change priority (#908) --- .changeset/smooth-seahorses-unite.md | 5 + .../analytics/__tests__/integration.test.ts | 58 +++ packages/browser/src/core/analytics/index.ts | 88 +++- .../storage/__tests__/cookieStorage.test.ts | 58 +++ .../storage/__tests__/localStorage.test.ts | 70 +++ .../core/storage/__tests__/test-helpers.ts | 27 ++ .../__tests__/universalStorage.test.ts | 144 +++++++ .../browser/src/core/storage/cookieStorage.ts | 80 ++++ packages/browser/src/core/storage/index.ts | 64 +++ .../browser/src/core/storage/localStorage.ts | 45 ++ .../browser/src/core/storage/memoryStorage.ts | 22 + packages/browser/src/core/storage/settings.ts | 23 + packages/browser/src/core/storage/types.ts | 49 +++ .../src/core/storage/universalStorage.ts | 66 +++ .../src/core/user/__tests__/index.test.ts | 352 ++++------------ .../src/core/user/__tests__/storage.test.ts | 15 - packages/browser/src/core/user/index.ts | 397 ++++-------------- .../src/plugins/segmentio/normalize.ts | 7 +- 18 files changed, 936 insertions(+), 634 deletions(-) create mode 100644 .changeset/smooth-seahorses-unite.md create mode 100644 packages/browser/src/core/storage/__tests__/cookieStorage.test.ts create mode 100644 packages/browser/src/core/storage/__tests__/localStorage.test.ts create mode 100644 packages/browser/src/core/storage/__tests__/test-helpers.ts create mode 100644 packages/browser/src/core/storage/__tests__/universalStorage.test.ts create mode 100644 packages/browser/src/core/storage/cookieStorage.ts create mode 100644 packages/browser/src/core/storage/index.ts create mode 100644 packages/browser/src/core/storage/localStorage.ts create mode 100644 packages/browser/src/core/storage/memoryStorage.ts create mode 100644 packages/browser/src/core/storage/settings.ts create mode 100644 packages/browser/src/core/storage/types.ts create mode 100644 packages/browser/src/core/storage/universalStorage.ts delete mode 100644 packages/browser/src/core/user/__tests__/storage.test.ts diff --git a/.changeset/smooth-seahorses-unite.md b/.changeset/smooth-seahorses-unite.md new file mode 100644 index 000000000..ef526d79c --- /dev/null +++ b/.changeset/smooth-seahorses-unite.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': minor +--- + +Adds storage option in analytics client to specify priority of storage (e.g use cookies over localstorage) diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts index 64711a803..73337107d 100644 --- a/packages/browser/src/core/analytics/__tests__/integration.test.ts +++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts @@ -7,7 +7,9 @@ import { import { Context } from '../../context' import { Plugin } from '../../plugin' import { EventQueue } from '../../queue/event-queue' +import { StoreType } from '../../storage' import { Analytics } from '../index' +import jar from 'js-cookie' import { TestAfterPlugin, TestBeforePlugin, @@ -271,4 +273,60 @@ describe('Analytics', () => { expect(fn).toHaveBeenCalledTimes(1) }) }) + + describe('storage', () => { + beforeEach(() => { + clearAjsBrowserStorage() + }) + + it('handles custom priority storage', async () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const expected = 'CookieValue' + jar.set('ajs_anonymous_id', expected) + localStorage.setItem('ajs_anonymous_id', 'localStorageValue') + + const analytics = new Analytics( + { writeKey: '' }, + { + storage: { + stores: [ + StoreType.Cookie, + StoreType.LocalStorage, + StoreType.Memory, + ], + }, + } + ) + + expect(analytics.user().anonymousId()).toEqual(expected) + + analytics.user().id('known-user') + expect(analytics.user().id()).toEqual('known-user') + expect(setCookieSpy).toHaveBeenCalled() + }) + + it('handles disabling storage', async () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const expected = 'CookieValue' + jar.set('ajs_anonymous_id', expected) + localStorage.setItem('ajs_anonymous_id', 'localStorageValue') + + const analytics = new Analytics( + { writeKey: '' }, + { + storage: { + stores: [StoreType.Cookie, StoreType.Memory], + }, + } + ) + + expect(analytics.user().anonymousId()).toEqual(expected) + + analytics.user().id('known-user') + expect(analytics.user().id()).toEqual('known-user') + expect(setCookieSpy).toHaveBeenCalled() + // Local storage shouldn't change + expect(localStorage.getItem('ajs_anonymous_id')).toBe('localStorageValue') + }) + }) }) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index 0ffe4eb9b..ef88ed131 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -24,15 +24,7 @@ import { } from '../events' import type { Plugin } from '../plugin' import { EventQueue } from '../queue/event-queue' -import { - CookieOptions, - getAvailableStorageOptions, - Group, - ID, - UniversalStorage, - User, - UserOptions, -} from '../user' +import { Group, ID, User, UserOptions } from '../user' import autoBind from '../../lib/bind-all' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' import type { LegacyDestination } from '../../plugins/ajs-destination' @@ -50,6 +42,16 @@ import { getGlobal } from '../../lib/get-global' import { AnalyticsClassic, AnalyticsCore } from './interfaces' import { HighEntropyHint } from '../../lib/client-hints/interfaces' import type { LegacySettings } from '../../browser' +import { + CookieOptions, + MemoryStorage, + UniversalStorage, + StorageSettings, + StoreType, + applyCookieOptions, + initializeStorages, + isArrayOfStoreType, +} from '../storage' const deprecationWarning = 'This is being deprecated and will be not be available in future releases of Analytics JS' @@ -93,6 +95,7 @@ export interface InitOptions { disableAutoISOConversion?: boolean initialPageview?: boolean cookie?: CookieOptions + storage?: StorageSettings user?: UserOptions group?: UserOptions integrations?: Integrations @@ -133,9 +136,7 @@ export class Analytics private _group: Group private eventFactory: EventFactory private _debug = false - private _universalStorage: UniversalStorage<{ - [k: string]: unknown - }> + private _universalStorage: UniversalStorage initialized = false integrations: Integrations @@ -162,25 +163,33 @@ export class Analytics disablePersistance ) - this._universalStorage = new UniversalStorage( - disablePersistance ? ['memory'] : ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions(cookieOptions) + const storageSetting = options?.storage + this._universalStorage = this.createStore( + disablePersistance, + storageSetting, + cookieOptions ) this._user = user ?? new User( - disablePersistance - ? { ...options?.user, persist: false } - : options?.user, + { + persist: !disablePersistance, + storage: options?.storage, + // Any User specific options override everything else + ...options?.user, + }, cookieOptions ).load() this._group = group ?? new Group( - disablePersistance - ? { ...options?.group, persist: false } - : options?.group, + { + persist: !disablePersistance, + storage: options?.storage, + // Any group specific options override everything else + ...options?.group, + }, cookieOptions ).load() this.eventFactory = new EventFactory(this._user) @@ -194,6 +203,43 @@ export class Analytics return this._user } + /** + * Creates the storage system based on the settings received + * @returns Storage + */ + private createStore( + disablePersistance: boolean, + storageSetting: InitOptions['storage'], + cookieOptions?: CookieOptions | undefined + ): UniversalStorage { + // DisablePersistance option overrides all, no storage will be used outside of memory even if specified + if (disablePersistance) { + return new UniversalStorage([new MemoryStorage()]) + } else { + if (storageSetting) { + if (isArrayOfStoreType(storageSetting)) { + // We will create the store with the priority for customer settings + return new UniversalStorage( + initializeStorages( + applyCookieOptions(storageSetting.stores, cookieOptions) + ) + ) + } + } + } + // We default to our multi storage with priority + return new UniversalStorage( + initializeStorages([ + StoreType.LocalStorage, + { + name: StoreType.Cookie, + settings: cookieOptions, + }, + StoreType.Memory, + ]) + ) + } + get storage(): UniversalStorage { return this._universalStorage } diff --git a/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts new file mode 100644 index 000000000..3ac9dd34f --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/cookieStorage.test.ts @@ -0,0 +1,58 @@ +import { CookieStorage } from '../cookieStorage' +import jar from 'js-cookie' + +describe('cookieStorage', () => { + function clearCookies() { + document.cookie.split(';').forEach(function (c) { + document.cookie = c + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/') + }) + } + + afterEach(() => { + clearCookies() + }) + + describe('cookie options', () => { + it('should have default cookie options', () => { + const cookie = new CookieStorage() + expect(cookie['options'].domain).toBe(undefined) + expect(cookie['options'].maxage).toBe(365) + expect(cookie['options'].path).toBe('/') + expect(cookie['options'].sameSite).toBe('Lax') + expect(cookie['options'].secure).toBe(undefined) + }) + + it('should set options properly', () => { + const cookie = new CookieStorage({ + domain: 'foo', + secure: true, + path: '/test', + }) + expect(cookie['options'].domain).toBe('foo') + expect(cookie['options'].secure).toBe(true) + expect(cookie['options'].path).toBe('/test') + expect(cookie['options'].secure).toBe(true) + }) + + it('should pass options when creating cookie', () => { + const jarSpy = jest.spyOn(jar, 'set') + const cookie = new CookieStorage({ + domain: 'foo', + secure: true, + path: '/test', + }) + + cookie.set('foo', 'bar') + + expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', { + domain: 'foo', + expires: 365, + path: '/test', + sameSite: 'Lax', + secure: true, + }) + }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/localStorage.test.ts b/packages/browser/src/core/storage/__tests__/localStorage.test.ts new file mode 100644 index 000000000..09c3b9f2d --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/localStorage.test.ts @@ -0,0 +1,70 @@ +import { LocalStorage } from '../localStorage' + +describe('LocalStorage', function () { + let store: LocalStorage + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. + store = new LocalStorage() + }) + + afterEach(() => { + localStorage.clear() + }) + + describe('#get', function () { + it('should return null if localStorage throws an error (or does not exist)', function () { + const getItemSpy = jest + .spyOn(global.Storage.prototype, 'getItem') + .mockImplementationOnce(() => { + throw new Error('getItem fail.') + }) + store.set('foo', 'some value') + expect(store.get('foo')).toBeNull() + expect(getItemSpy).toBeCalledTimes(1) + }) + + it('should not get an empty record', function () { + expect(store.get('abc')).toBe(null) + }) + + it('should get an existing record', function () { + store.set('x', { a: 'b' }) + store.set('a', 'hello world') + store.set('b', '') + store.set('c', false) + store.set('d', null) + store.set('e', undefined) + + expect(store.get('x')).toStrictEqual({ a: 'b' }) + expect(store.get('a')).toBe('hello world') + expect(store.get('b')).toBe('') + expect(store.get('c')).toBe(false) + expect(store.get('d')).toBe(null) + expect(store.get('e')).toBe('undefined') + }) + }) + + describe('#set', function () { + it('should be able to set a record', function () { + store.set('x', { a: 'b' }) + expect(store.get('x')).toStrictEqual({ a: 'b' }) + }) + + it('should catch localStorage quota exceeded errors', () => { + const val = 'x'.repeat(10 * 1024 * 1024) + store.set('foo', val) + + expect(store.get('foo')).toBe(null) + }) + }) + + describe('#clear', function () { + it('should be able to remove a record', function () { + store.set('x', { a: 'b' }) + expect(store.get('x')).toStrictEqual({ a: 'b' }) + store.remove('x') + expect(store.get('x')).toBe(null) + }) + }) +}) diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts new file mode 100644 index 000000000..20faf069a --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts @@ -0,0 +1,27 @@ +import jar from 'js-cookie' +/** + * Disables Cookies + * @returns jest spy + */ +export function disableCookies(): void { + jest.spyOn(window.navigator, 'cookieEnabled', 'get').mockReturnValue(false) + jest.spyOn(jar, 'set').mockImplementation(() => { + throw new Error() + }) + jest.spyOn(jar, 'get').mockImplementation(() => { + throw new Error() + }) +} + +/** + * Disables LocalStorage + * @returns jest spy + */ +export function disableLocalStorage(): void { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error() + }) + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error() + }) +} diff --git a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts new file mode 100644 index 000000000..3c813ebb9 --- /dev/null +++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts @@ -0,0 +1,144 @@ +import jar from 'js-cookie' +import { CookieStorage } from '../cookieStorage' +import { LocalStorage } from '../localStorage' +import { MemoryStorage } from '../memoryStorage' +import { UniversalStorage } from '../universalStorage' +import { disableCookies, disableLocalStorage } from './test-helpers' + +describe('UniversalStorage', function () { + const defaultTargets = [ + new CookieStorage(), + new LocalStorage(), + new MemoryStorage(), + ] + const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '') + beforeEach(function () { + clear() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + function clear(): void { + document.cookie.split(';').forEach(function (c) { + document.cookie = c + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/') + }) + localStorage.clear() + } + + describe('#get', function () { + it('picks data from cookies first', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage(defaultTargets) + expect(us.get('ajs_test_key')).toEqual('🍊') + }) + + it('picks data from localStorage if there is no cookie target', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()]) + expect(us.get('ajs_test_key')).toEqual('ðŸ’ū') + }) + + it('get data from memory', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage([new MemoryStorage()]) + expect(us.get('ajs_test_key')).toBeNull() + }) + + it('order of default targets matters!', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage([ + new LocalStorage(), + new CookieStorage(), + new MemoryStorage(), + ]) + expect(us.get('ajs_test_key')).toEqual('ðŸ’ū') + }) + + it('returns null if there are no storage targets', function () { + jar.set('ajs_test_key', '🍊') + localStorage.setItem('ajs_test_key', 'ðŸ’ū') + const us = new UniversalStorage([]) + expect(us.get('ajs_test_key')).toBeNull() + }) + }) + + describe('#set', function () { + it('sets the data in all storage types', function () { + const us = new UniversalStorage<{ ajs_test_key: string }>(defaultTargets) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual('💰') + expect(getFromLS('ajs_test_key')).toEqual('💰') + }) + + it('skip saving data to localStorage', function () { + const us = new UniversalStorage([ + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual('💰') + expect(localStorage.getItem('ajs_test_key')).toEqual(null) + }) + + it('skip saving data to cookie', function () { + const us = new UniversalStorage([new LocalStorage(), new MemoryStorage()]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(getFromLS('ajs_test_key')).toEqual('💰') + }) + + it('can save and retrieve from memory when there is no other storage', function () { + const us = new UniversalStorage([new MemoryStorage()]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual(undefined) + expect(localStorage.getItem('ajs_test_key')).toEqual(null) + expect(us.get('ajs_test_key')).toEqual('💰') + }) + + it('handles cookie errors gracefully', function () { + disableCookies() // Cookies is going to throw exceptions now + const us = new UniversalStorage([ + new LocalStorage(), + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', '💰') + expect(getFromLS('ajs_test_key')).toEqual('💰') + expect(us.get('ajs_test_key')).toEqual('💰') + }) + + it('does not write to LS when LS is not available', function () { + disableLocalStorage() // Localstorage will throw exceptions + const us = new UniversalStorage([ + new LocalStorage(), + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', '💰') + expect(jar.get('ajs_test_key')).toEqual('💰') + expect(us.get('ajs_test_key')).toEqual('💰') + }) + + it('handles cookie getter overrides gracefully', function () { + ;(document as any).__defineGetter__('cookie', function () { + return '' + }) + const us = new UniversalStorage([ + new LocalStorage(), + new CookieStorage(), + new MemoryStorage(), + ]) + us.set('ajs_test_key', '💰') + expect(getFromLS('ajs_test_key')).toEqual('💰') + expect(us.get('ajs_test_key')).toEqual('💰') + }) + }) +}) diff --git a/packages/browser/src/core/storage/cookieStorage.ts b/packages/browser/src/core/storage/cookieStorage.ts new file mode 100644 index 000000000..9450d55d9 --- /dev/null +++ b/packages/browser/src/core/storage/cookieStorage.ts @@ -0,0 +1,80 @@ +import { Store, StorageObject } from './types' +import jar from 'js-cookie' +import { tld } from '../user/tld' + +const ONE_YEAR = 365 + +export interface CookieOptions { + maxage?: number + domain?: string + path?: string + secure?: boolean + sameSite?: string +} + +/** + * Data storage using browser cookies + */ +export class CookieStorage + implements Store +{ + static get defaults(): CookieOptions { + return { + maxage: ONE_YEAR, + domain: tld(window.location.href), + path: '/', + sameSite: 'Lax', + } + } + + private options: Required + + constructor(options: CookieOptions = CookieStorage.defaults) { + this.options = { + ...CookieStorage.defaults, + ...options, + } as Required + } + + private opts(): jar.CookieAttributes { + return { + sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'], + expires: this.options.maxage, + domain: this.options.domain, + path: this.options.path, + secure: this.options.secure, + } + } + + get(key: K): Data[K] | null { + try { + const value = jar.get(key) + + if (value === undefined || value === null) { + return null + } + + try { + return JSON.parse(value) ?? null + } catch (e) { + return (value ?? null) as unknown as Data[K] | null + } + } catch (e) { + return null + } + } + + set(key: K, value: Data[K] | null): void { + if (typeof value === 'string') { + jar.set(key, value, this.opts()) + } else if (value === null) { + jar.remove(key, this.opts()) + } else { + jar.set(key, JSON.stringify(value), this.opts()) + } + } + + remove(key: K): void { + return jar.remove(key, this.opts()) + } +} diff --git a/packages/browser/src/core/storage/index.ts b/packages/browser/src/core/storage/index.ts new file mode 100644 index 000000000..aeb9a3b84 --- /dev/null +++ b/packages/browser/src/core/storage/index.ts @@ -0,0 +1,64 @@ +import { CookieOptions, CookieStorage } from './cookieStorage' +import { LocalStorage } from './localStorage' +import { MemoryStorage } from './memoryStorage' +import { isStoreTypeWithSettings } from './settings' +import { StoreType, Store, InitializeStorageArgs } from './types' + +export * from './types' +export * from './localStorage' +export * from './cookieStorage' +export * from './memoryStorage' +export * from './universalStorage' +export * from './settings' + +/** + * Creates multiple storage systems from an array of StoreType and options + * @param args StoreType and options + * @returns Storage array + */ +export function initializeStorages(args: InitializeStorageArgs): Store[] { + const storages = args.map((s) => { + let type: StoreType + let settings + + if (isStoreTypeWithSettings(s)) { + type = s.name + settings = s.settings + } else { + type = s + } + + switch (type) { + case StoreType.Cookie: + return new CookieStorage(settings) + case StoreType.LocalStorage: + return new LocalStorage() + case StoreType.Memory: + return new MemoryStorage() + default: + throw new Error(`Unknown Store Type: ${s}`) + } + }) + return storages +} + +/** + * Injects the CookieOptions into a the arguments for initializeStorage + * @param storeTypes list of storeType + * @param cookieOptions cookie Options + * @returns arguments for initializeStorage + */ +export function applyCookieOptions( + storeTypes: StoreType[], + cookieOptions?: CookieOptions +): InitializeStorageArgs { + return storeTypes.map((s) => { + if (cookieOptions && s === StoreType.Cookie) { + return { + name: s, + settings: cookieOptions, + } + } + return s + }) +} diff --git a/packages/browser/src/core/storage/localStorage.ts b/packages/browser/src/core/storage/localStorage.ts new file mode 100644 index 000000000..9eb369d77 --- /dev/null +++ b/packages/browser/src/core/storage/localStorage.ts @@ -0,0 +1,45 @@ +import { StorageObject, Store } from './types' + +/** + * Data storage using browser's localStorage + */ +export class LocalStorage + implements Store +{ + private localStorageWarning(key: keyof Data, state: 'full' | 'unavailable') { + console.warn(`Unable to access ${key}, localStorage may be ${state}`) + } + + get(key: K): Data[K] | null { + try { + const val = localStorage.getItem(key) + if (val === null) { + return null + } + try { + return JSON.parse(val) ?? null + } catch (e) { + return (val ?? null) as unknown as Data[K] | null + } + } catch (err) { + this.localStorageWarning(key, 'unavailable') + return null + } + } + + set(key: K, value: Data[K] | null): void { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + this.localStorageWarning(key, 'full') + } + } + + remove(key: K): void { + try { + return localStorage.removeItem(key) + } catch (err) { + this.localStorageWarning(key, 'unavailable') + } + } +} diff --git a/packages/browser/src/core/storage/memoryStorage.ts b/packages/browser/src/core/storage/memoryStorage.ts new file mode 100644 index 000000000..7840e7ba7 --- /dev/null +++ b/packages/browser/src/core/storage/memoryStorage.ts @@ -0,0 +1,22 @@ +import { Store, StorageObject } from './types' + +/** + * Data Storage using in memory object + */ +export class MemoryStorage + implements Store +{ + private cache: Record = {} + + get(key: K): Data[K] | null { + return (this.cache[key] ?? null) as Data[K] | null + } + + set(key: K, value: Data[K] | null): void { + this.cache[key] = value + } + + remove(key: K): void { + delete this.cache[key] + } +} diff --git a/packages/browser/src/core/storage/settings.ts b/packages/browser/src/core/storage/settings.ts new file mode 100644 index 000000000..cc11a58fb --- /dev/null +++ b/packages/browser/src/core/storage/settings.ts @@ -0,0 +1,23 @@ +import { StoreType, StoreTypeWithSettings } from './types' + +export type UniversalStorageSettings = { stores: StoreType[] } + +// This is setup this way to permit eventually a different set of settings for custom storage +export type StorageSettings = UniversalStorageSettings + +export function isArrayOfStoreType( + s: StorageSettings +): s is UniversalStorageSettings { + return ( + s && + s.stores && + Array.isArray(s.stores) && + s.stores.every((e) => Object.values(StoreType).includes(e)) + ) +} + +export function isStoreTypeWithSettings( + s: StoreTypeWithSettings | StoreType +): s is StoreTypeWithSettings { + return typeof s === 'object' && s.name !== undefined +} diff --git a/packages/browser/src/core/storage/types.ts b/packages/browser/src/core/storage/types.ts new file mode 100644 index 000000000..e64bc0f1a --- /dev/null +++ b/packages/browser/src/core/storage/types.ts @@ -0,0 +1,49 @@ +import { CookieOptions } from './cookieStorage' + +export const StoreType = { + Cookie: 'cookie', + LocalStorage: 'localStorage', + Memory: 'memory', +} as const + +/** + * Known Storage Types + * + * Convenience settings for storage systems that AJS includes support for + */ +export type StoreType = typeof StoreType[keyof typeof StoreType] + +export type StorageObject = Record + +/** + * Defines a Storage object for use in AJS Client. + */ +export interface Store { + /** + * get value for the key from the stores. it will return the first value found in the stores + * @param key key for the value to be retrieved + * @returns value for the key or null if not found + */ + get(key: K): Data[K] | null + + /** + * it will set the value for the key in all the stores + * @param key key for the value to be stored + * @param value value to be stored + * @returns value that was stored + */ + set(key: K, value: Data[K] | null): void + /** + * remove the value for the key from all the stores + * @param key key for the value to be removed + * @param storeTypes optional array of store types to be used for removing the value + */ + remove(key: K): void +} + +export interface StoreTypeWithSettings { + name: T + settings?: T extends 'cookie' ? CookieOptions : never +} + +export type InitializeStorageArgs = (StoreTypeWithSettings | StoreType)[] diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts new file mode 100644 index 000000000..41cebf926 --- /dev/null +++ b/packages/browser/src/core/storage/universalStorage.ts @@ -0,0 +1,66 @@ +import { Store, StorageObject } from './types' + +/** + * Uses multiple storages in a priority list to get/set values in the order they are specified. + */ +export class UniversalStorage { + private stores: Store[] + + constructor(stores: Store[]) { + this.stores = stores + } + + get(key: K): Data[K] | null { + let val: Data[K] | null = null + + for (const store of this.stores) { + try { + val = store.get(key) as Data[K] | null + if (val !== undefined && val !== null) { + return val + } + } catch (e) { + console.warn(`Can't access ${key}: ${e}`) + } + } + return null + } + + set(key: K, value: Data[K] | null): void { + this.stores.forEach((s) => { + try { + s.set(key, value) + } catch (e) { + console.warn(`Can't set ${key}: ${e}`) + } + }) + } + + clear(key: K): void { + this.stores.forEach((s) => { + try { + s.remove(key) + } catch (e) { + console.warn(`Can't remove ${key}: ${e}`) + } + }) + } + + /* + This is to support few scenarios where: + - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them + - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format + */ + getAndSync(key: K): Data[K] | null { + const val = this.get(key) + + // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) + const coercedValue = (typeof val === 'number' ? val.toString() : val) as + | Data[K] + | null + + this.set(key, coercedValue) + + return coercedValue + } +} diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts index 287d2e0dc..87f172189 100644 --- a/packages/browser/src/core/user/__tests__/index.test.ts +++ b/packages/browser/src/core/user/__tests__/index.test.ts @@ -1,14 +1,11 @@ -import { - User, - LocalStorage, - Cookie, - Group, - UniversalStorage, - StoreType, - getAvailableStorageOptions, -} from '..' -import jar from 'js-cookie' import assert from 'assert' +import jar from 'js-cookie' +import { Group, User } from '..' +import { LocalStorage, StoreType } from '../../storage' +import { + disableCookies, + disableLocalStorage, +} from '../../storage/__tests__/test-helpers' function clear(): void { document.cookie.split(';').forEach(function (c) { @@ -37,10 +34,11 @@ let store: LocalStorage beforeEach(function () { store = new LocalStorage() clear() + // Restore any cookie, localstorage disable + jest.restoreAllMocks() + jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. }) -jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. - describe('user', () => { const cookieKey = User.defaults.cookie.key const localStorageKey = User.defaults.localStorage.key @@ -90,7 +88,7 @@ describe('user', () => { describe('when cookies are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() user = new User() clear() @@ -154,8 +152,8 @@ describe('user', () => { describe('when cookies and localStorage are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) + disableCookies() + disableLocalStorage() user = new User() clear() @@ -164,9 +162,6 @@ describe('user', () => { it('should get an id from memory', () => { user.id('id') assert(user.id() === 'id') - - expect(jar.get(cookieKey)).toBeFalsy() - expect(store.get(cookieKey)).toBeFalsy() }) it('should get an id when not persisting', () => { @@ -175,10 +170,6 @@ describe('user', () => { assert(user.id() === 'id') }) - it('should be null by default', () => { - assert(user.id() === null) - }) - it('should not reset anonymousId if the user didnt have previous id', () => { const prev = user.anonymousId() user.id('foo') @@ -216,9 +207,6 @@ describe('user', () => { it('should get an id from memory', () => { user.id('id') assert(user.id() === 'id') - - expect(jar.get(cookieKey)).toBeFalsy() - expect(store.get(cookieKey)).toBeFalsy() }) it('should be null by default', () => { @@ -322,7 +310,7 @@ describe('user', () => { describe('when cookies are disabled', () => { beforeEach(() => { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() user = new User() }) @@ -350,8 +338,8 @@ describe('user', () => { describe('when cookies and localStorage are disabled', () => { beforeEach(() => { - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) + disableCookies() + disableLocalStorage() user = new User() }) @@ -359,7 +347,6 @@ describe('user', () => { it('should get an id from memory', () => { user.anonymousId('anon-id') assert(user.anonymousId() === 'anon-id') - expect(jar.get('ajs_anonymous_id')).toBeFalsy() }) }) @@ -518,40 +505,6 @@ describe('user', () => { }) }) - describe('#options', () => { - it('should have default cookie options', () => { - const cookie = new Cookie() - expect(cookie['options'].domain).toBe(undefined) - expect(cookie['options'].maxage).toBe(365) - expect(cookie['options'].path).toBe('/') - expect(cookie['options'].sameSite).toBe('Lax') - expect(cookie['options'].secure).toBe(undefined) - }) - - it('should set options properly', () => { - const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' }) - expect(cookie['options'].domain).toBe('foo') - expect(cookie['options'].secure).toBe(true) - expect(cookie['options'].path).toBe('/test') - expect(cookie['options'].secure).toBe(true) - }) - - it('should pass options when creating cookie', () => { - const jarSpy = jest.spyOn(jar, 'set') - const cookie = new Cookie({ domain: 'foo', secure: true, path: '/test' }) - - cookie.set('foo', 'bar') - - expect(jarSpy).toHaveBeenCalledWith('foo', 'bar', { - domain: 'foo', - expires: 365, - path: '/test', - sameSite: 'Lax', - secure: true, - }) - }) - }) - describe('#save', () => { let user: User @@ -776,6 +729,67 @@ describe('user', () => { ) }) }) + + describe('storage', () => { + it('allows custom storage priority', () => { + const expected = 'CookieValue' + // Set a cookie first + jar.set('ajs_anonymous_id', expected) + store.set('ajs_anonymous_id', 'localStorageValue') + const user = new User({ + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, + }) + expect(user.anonymousId()).toEqual(expected) + }) + + it('custom storage priority respects availability', () => { + const expected = 'localStorageValue' + // Set a cookie first + jar.set('ajs_anonymous_id', 'CookieValue') + disableCookies() + store.set('ajs_anonymous_id', expected) + const user = new User({ + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, + }) + expect(user.anonymousId()).toEqual(expected) + }) + + it('persist option overrides any custom storage', () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const user = new User({ + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, + persist: false, + }) + user.id('id') + + expect(user.id()).toBe('id') + expect(jar.get('ajs_user_id')).toBeFalsy() + expect(store.get('ajs_user_id')).toBeFalsy() + expect(setCookieSpy.mock.calls.length).toBe(0) + }) + + it('disable option overrides any custom storage', () => { + const setCookieSpy = jest.spyOn(jar, 'set') + const user = new User({ + storage: { + stores: [StoreType.Cookie, StoreType.LocalStorage, StoreType.Memory], + }, + disable: true, + }) + user.id('id') + + expect(user.id()).toBe(null) + expect(jar.get('ajs_user_id')).toBeFalsy() + expect(store.get('ajs_user_id')).toBeFalsy() + expect(setCookieSpy.mock.calls.length).toBe(0) + }) + }) }) describe('group', () => { @@ -886,64 +900,6 @@ describe('group', () => { }) }) -describe('store', function () { - describe('#get', function () { - it('should return null if localStorage throws an error (or does not exist)', function () { - const getItemSpy = jest - .spyOn(global.Storage.prototype, 'getItem') - .mockImplementationOnce(() => { - throw new Error('getItem fail.') - }) - store.set('foo', 'some value') - expect(store.get('foo')).toBeNull() - expect(getItemSpy).toBeCalledTimes(1) - }) - - it('should not get an empty record', function () { - expect(store.get('abc')).toBe(null) - }) - - it('should get an existing record', function () { - store.set('x', { a: 'b' }) - store.set('a', 'hello world') - store.set('b', '') - store.set('c', false) - store.set('d', null) - store.set('e', undefined) - - expect(store.get('x')).toStrictEqual({ a: 'b' }) - expect(store.get('a')).toBe('hello world') - expect(store.get('b')).toBe('') - expect(store.get('c')).toBe(false) - expect(store.get('d')).toBe(null) - expect(store.get('e')).toBe('undefined') - }) - }) - - describe('#set', function () { - it('should be able to set a record', function () { - store.set('x', { a: 'b' }) - expect(store.get('x')).toStrictEqual({ a: 'b' }) - }) - - it('should catch localStorage quota exceeded errors', () => { - const val = 'x'.repeat(10 * 1024 * 1024) - store.set('foo', val) - - expect(store.get('foo')).toBe(null) - }) - }) - - describe('#remove', function () { - it('should be able to remove a record', function () { - store.set('x', { a: 'b' }) - expect(store.get('x')).toStrictEqual({ a: 'b' }) - store.remove('x') - expect(store.get('x')).toBe(null) - }) - }) -}) - describe('Custom cookie params', () => { it('allows for overriding keys', () => { const customUser = new User( @@ -961,157 +917,3 @@ describe('Custom cookie params', () => { expect(customUser.traits()).toEqual({ trait: true }) }) }) - -describe('universal storage', function () { - const defaultTargets = ['cookie', 'localStorage', 'memory'] as StoreType[] - const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '') - beforeEach(function () { - clear() - }) - - describe('#get', function () { - it('picks data from cookies first', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('🍊') - }) - - it('picks data from localStorage if there is no cookie target', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - ['localStorage', 'memory'], - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('ðŸ’ū') - }) - - it('get data from memory', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage(['memory'], getAvailableStorageOptions()) - expect(us.get('ajs_test_key')).toBeNull() - }) - - it('order of default targets matters!', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - ['cookie', 'localStorage', 'memory'], - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key')).toEqual('🍊') - }) - - it('returns null if there are no storage targets', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage([], getAvailableStorageOptions()) - expect(us.get('ajs_test_key')).toBeNull() - }) - - it('can override the default targets', function () { - jar.set('ajs_test_key', '🍊') - localStorage.setItem('ajs_test_key', 'ðŸ’ū') - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - expect(us.get('ajs_test_key', ['localStorage'])).toEqual('ðŸ’ū') - expect(us.get('ajs_test_key', ['localStorage', 'memory'])).toEqual('ðŸ’ū') - expect(us.get('ajs_test_key', ['cookie', 'memory'])).toEqual('🍊') - expect(us.get('ajs_test_key', ['cookie', 'localStorage'])).toEqual('🍊') - expect(us.get('ajs_test_key', ['cookie'])).toEqual('🍊') - expect(us.get('ajs_test_key', ['memory'])).toEqual(null) - }) - }) - - describe('#set', function () { - it('set the data in all storage types', function () { - const us = new UniversalStorage<{ ajs_test_key: string }>( - defaultTargets, - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual('💰') - expect(getFromLS('ajs_test_key')).toEqual('💰') - }) - - it('skip saving data to localStorage', function () { - const us = new UniversalStorage( - ['cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual('💰') - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - }) - - it('skip saving data to cookie', function () { - const us = new UniversalStorage( - ['localStorage', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('💰') - }) - - it('can save and retrieve from memory when there is no other storage', function () { - const us = new UniversalStorage(['memory'], getAvailableStorageOptions()) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - expect(us.get('ajs_test_key')).toEqual('💰') - }) - - it('does not write to cookies when cookies are not available', function () { - jest.spyOn(Cookie, 'available').mockReturnValueOnce(false) - const us = new UniversalStorage( - ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('💰') - expect(us.get('ajs_test_key')).toEqual('💰') - }) - - it('does not write to LS when LS is not available', function () { - jest.spyOn(LocalStorage, 'available').mockReturnValueOnce(false) - const us = new UniversalStorage( - ['localStorage', 'cookie', 'memory'], - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰') - expect(jar.get('ajs_test_key')).toEqual('💰') - expect(localStorage.getItem('ajs_test_key')).toEqual(null) - expect(us.get('ajs_test_key')).toEqual('💰') - }) - - it('can override the default targets', function () { - const us = new UniversalStorage( - defaultTargets, - getAvailableStorageOptions() - ) - us.set('ajs_test_key', '💰', ['localStorage']) - expect(jar.get('ajs_test_key')).toEqual(undefined) - expect(getFromLS('ajs_test_key')).toEqual('💰') - expect(us.get('ajs_test_key')).toEqual('💰') - - us.set('ajs_test_key_2', 'ðŸĶī', ['cookie']) - expect(jar.get('ajs_test_key_2')).toEqual('ðŸĶī') - expect(localStorage.getItem('ajs_test_key_2')).toEqual(null) - expect(us.get('ajs_test_key_2')).toEqual('ðŸĶī') - - us.set('ajs_test_key_3', 'ðŸ‘ŧ', []) - expect(jar.get('ajs_test_key_3')).toEqual(undefined) - expect(localStorage.getItem('ajs_test_key_3')).toEqual(null) - expect(us.get('ajs_test_key_3')).toEqual(null) - }) - }) -}) diff --git a/packages/browser/src/core/user/__tests__/storage.test.ts b/packages/browser/src/core/user/__tests__/storage.test.ts deleted file mode 100644 index 30100a30c..000000000 --- a/packages/browser/src/core/user/__tests__/storage.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Cookie } from '..' - -describe('Cookie storage', () => { - it('should report cookie storage available when cookies are accessible', () => { - expect(Cookie.available()).toBe(true) - }) - - it('should report cookie storage unavailable when cookies are not accessible', () => { - ;(document as any).__defineGetter__('cookie', function () { - return '' - }) - - expect(Cookie.available()).toBe(false) - }) -}) diff --git a/packages/browser/src/core/user/index.ts b/packages/browser/src/core/user/index.ts index 5949d5fad..cd9d86dc6 100644 --- a/packages/browser/src/core/user/index.ts +++ b/packages/browser/src/core/user/index.ts @@ -1,8 +1,17 @@ import { v4 as uuid } from '@lukeed/uuid' -import jar from 'js-cookie' -import { Traits } from '../events' -import { tld } from './tld' import autoBind from '../../lib/bind-all' +import { Traits } from '../events' +import { + CookieOptions, + UniversalStorage, + MemoryStorage, + StorageObject, + StorageSettings, + StoreType, + applyCookieOptions, + initializeStorages, + isArrayOfStoreType, +} from '../storage' export type ID = string | null | undefined @@ -22,6 +31,12 @@ export interface UserOptions { localStorage?: { key: string } + + /** + * Store priority + * @example stores: [StoreType.Cookie, StoreType.Memory] + */ + storage?: StorageSettings } const defaults = { @@ -35,290 +50,6 @@ const defaults = { }, } -export type StoreType = 'cookie' | 'localStorage' | 'memory' - -type StorageObject = Record - -class Store { - private cache: Record = {} - - get(key: string): T | null { - return this.cache[key] as T | null - } - - set(key: string, value: T | null): void { - this.cache[key] = value - } - - remove(key: string): void { - delete this.cache[key] - } - get type(): StoreType { - return 'memory' - } -} - -const ONE_YEAR = 365 - -export class Cookie extends Store { - static available(): boolean { - try { - const PROBE_COOKIE = 'ajs_cookies_check' - jar.set(PROBE_COOKIE, 'test') - const cookieEnabled = document.cookie.includes(PROBE_COOKIE) - jar.remove(PROBE_COOKIE) - return cookieEnabled - } catch (error) { - return false - } - } - - static get defaults(): CookieOptions { - return { - maxage: ONE_YEAR, - domain: tld(window.location.href), - path: '/', - sameSite: 'Lax', - } - } - - private options: Required - - constructor(options: CookieOptions = Cookie.defaults) { - super() - this.options = { - ...Cookie.defaults, - ...options, - } as Required - } - - private opts(): jar.CookieAttributes { - return { - sameSite: this.options.sameSite as jar.CookieAttributes['sameSite'], - expires: this.options.maxage, - domain: this.options.domain, - path: this.options.path, - secure: this.options.secure, - } - } - - get(key: string): T | null { - try { - const value = jar.get(key) - - if (!value) { - return null - } - - try { - return JSON.parse(value) - } catch (e) { - return value as unknown as T - } - } catch (e) { - return null - } - } - - set(key: string, value: T): void { - if (typeof value === 'string') { - jar.set(key, value, this.opts()) - } else if (value === null) { - jar.remove(key, this.opts()) - } else { - jar.set(key, JSON.stringify(value), this.opts()) - } - } - - remove(key: string): void { - return jar.remove(key, this.opts()) - } - - get type(): StoreType { - return 'cookie' - } -} - -const localStorageWarning = (key: string, state: 'full' | 'unavailable') => { - console.warn(`Unable to access ${key}, localStorage may be ${state}`) -} - -export class LocalStorage extends Store { - static available(): boolean { - const test = 'test' - try { - localStorage.setItem(test, test) - localStorage.removeItem(test) - return true - } catch (e) { - return false - } - } - - get(key: string): T | null { - try { - const val = localStorage.getItem(key) - if (val === null) { - return null - } - try { - return JSON.parse(val) - } catch (e) { - return val as any as T - } - } catch (err) { - localStorageWarning(key, 'unavailable') - return null - } - } - - set(key: string, value: T): void { - try { - localStorage.setItem(key, JSON.stringify(value)) - } catch { - localStorageWarning(key, 'full') - } - } - - remove(key: string): void { - try { - return localStorage.removeItem(key) - } catch (err) { - localStorageWarning(key, 'unavailable') - } - } - - get type(): StoreType { - return 'localStorage' - } -} - -export interface CookieOptions { - maxage?: number - domain?: string - path?: string - secure?: boolean - sameSite?: string -} - -export class UniversalStorage { - private enabledStores: StoreType[] - private storageOptions: StorageOptions - - constructor(stores: StoreType[], storageOptions: StorageOptions) { - this.storageOptions = storageOptions - this.enabledStores = stores - } - - private getStores(storeTypes: StoreType[] | undefined): Store[] { - const stores: Store[] = [] - this.enabledStores - .filter((i) => !storeTypes || storeTypes?.includes(i)) - .forEach((storeType) => { - const storage = this.storageOptions[storeType] - if (storage !== undefined) { - stores.push(storage) - } - }) - - return stores - } - - /* - This is to support few scenarios where: - - value exist in one of the stores ( as a result of other stores being cleared from browser ) and we want to resync them - - read values in AJS 1.0 format ( for customers after 1.0 --> 2.0 migration ) and then re-write them in AJS 2.0 format - */ - - /** - * get value for the key from the stores. it will pick the first value found in the stores, and then sync the value to all the stores - * if the found value is a number, it will be converted to a string. this is to support legacy behavior that existed in AJS 1.0 - * @param key key for the value to be retrieved - * @param storeTypes optional array of store types to be used for performing get and sync - * @returns value for the key or null if not found - */ - public getAndSync( - key: K, - storeTypes?: StoreType[] - ): Data[K] | null { - const val = this.get(key, storeTypes) - - // legacy behavior, getAndSync can change the type of a value from number to string (AJS 1.0 stores numerical values as a number) - const coercedValue = (typeof val === 'number' ? val.toString() : val) as - | Data[K] - | null - - this.set(key, coercedValue, storeTypes) - - return coercedValue - } - - /** - * get value for the key from the stores. it will return the first value found in the stores - * @param key key for the value to be retrieved - * @param storeTypes optional array of store types to be used for retrieving the value - * @returns value for the key or null if not found - */ - public get( - key: K, - storeTypes?: StoreType[] - ): Data[K] | null { - let val = null - - for (const store of this.getStores(storeTypes)) { - val = store.get(key) - if (val) { - return val - } - } - return null - } - - /** - * it will set the value for the key in all the stores - * @param key key for the value to be stored - * @param value value to be stored - * @param storeTypes optional array of store types to be used for storing the value - * @returns value that was stored - */ - public set( - key: K, - value: Data[K] | null, - storeTypes?: StoreType[] - ): void { - for (const store of this.getStores(storeTypes)) { - store.set(key, value) - } - } - - /** - * remove the value for the key from all the stores - * @param key key for the value to be removed - * @param storeTypes optional array of store types to be used for removing the value - */ - public clear(key: K, storeTypes?: StoreType[]): void { - for (const store of this.getStores(storeTypes)) { - store.remove(key) - } - } -} - -type StorageOptions = { - cookie: Cookie | undefined - localStorage: LocalStorage | undefined - memory: Store -} - -export function getAvailableStorageOptions( - cookieOptions?: CookieOptions -): StorageOptions { - return { - cookie: Cookie.available() ? new Cookie(cookieOptions) : undefined, - localStorage: LocalStorage.available() ? new LocalStorage() : undefined, - memory: new Store(), - } -} - export class User { static defaults = defaults @@ -346,47 +77,27 @@ export class User { options: UserOptions = {} constructor(options: UserOptions = defaults, cookieOptions?: CookieOptions) { - this.options = options + this.options = { ...defaults, ...options } this.cookieOptions = cookieOptions this.idKey = options.cookie?.key ?? defaults.cookie.key this.traitsKey = options.localStorage?.key ?? defaults.localStorage.key this.anonKey = 'ajs_anonymous_id' - const isDisabled = options.disable === true - const shouldPersist = options.persist !== false - - let defaultStorageTargets: StoreType[] = isDisabled - ? [] - : shouldPersist - ? ['localStorage', 'cookie', 'memory'] - : ['memory'] - - const storageOptions = getAvailableStorageOptions(cookieOptions) - - if (options.localStorageFallbackDisabled) { - defaultStorageTargets = defaultStorageTargets.filter( - (t) => t !== 'localStorage' - ) - } - - this.identityStore = new UniversalStorage( - defaultStorageTargets, - storageOptions - ) + this.identityStore = this.createStorage(this.options, cookieOptions) // using only cookies for legacy user store - this.legacyUserStore = new UniversalStorage( - defaultStorageTargets.filter( - (t) => t !== 'localStorage' && t !== 'memory' - ), - storageOptions + this.legacyUserStore = this.createStorage( + this.options, + cookieOptions, + (s) => s === StoreType.Cookie ) // using only localStorage / memory for traits store - this.traitsStore = new UniversalStorage( - defaultStorageTargets.filter((t) => t !== 'cookie'), - storageOptions + this.traitsStore = this.createStorage( + this.options, + cookieOptions, + (s) => s !== StoreType.Cookie ) const legacyUser = this.legacyUserStore.get(defaults.cookie.oldKey) @@ -510,6 +221,56 @@ export class User { save(): boolean { return true } + + /** + * Creates the right storage system applying all the user options, cookie options and particular filters + * @param options UserOptions + * @param cookieOpts CookieOptions + * @param filterStores filter function to apply to any StoreTypes (skipped if options specify using a custom storage) + * @returns a Storage object + */ + private createStorage( + options: UserOptions, + cookieOpts?: CookieOptions, + filterStores?: (value: StoreType) => boolean + ): UniversalStorage { + let stores: StoreType[] = [ + StoreType.LocalStorage, + StoreType.Cookie, + StoreType.Memory, + ] + + // If disabled we won't have any storage functionality + if (options.disable) { + return new UniversalStorage([]) + } + + // If persistance is disabled we will always fallback to Memory Storage + if (!options.persist) { + return new UniversalStorage([new MemoryStorage()]) + } + + if (options.storage !== undefined && options.storage !== null) { + if (isArrayOfStoreType(options.storage)) { + // If the user only specified order of stores we will still apply filters and transformations e.g. not using localStorage if localStorageFallbackDisabled + stores = options.storage.stores + } + } + + // Disable LocalStorage + if (options.localStorageFallbackDisabled) { + stores = stores.filter((s) => s !== StoreType.LocalStorage) + } + + // Apply Additional filters + if (filterStores) { + stores = stores.filter(filterStores) + } + + return new UniversalStorage( + initializeStorages(applyCookieOptions(stores, cookieOpts)) + ) + } } const groupDefaults: UserOptions = { @@ -524,7 +285,7 @@ const groupDefaults: UserOptions = { export class Group extends User { constructor(options: UserOptions = groupDefaults, cookie?: CookieOptions) { - super(options, cookie) + super({ ...groupDefaults, ...options }, cookie) autoBind(this) } diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts index 61979958c..5f2df7f55 100644 --- a/packages/browser/src/plugins/segmentio/normalize.ts +++ b/packages/browser/src/plugins/segmentio/normalize.ts @@ -7,7 +7,7 @@ import { tld } from '../../core/user/tld' import { SegmentFacade } from '../../lib/to-facade' import { SegmentioSettings } from './index' import { version } from '../../generated/version' -import { getAvailableStorageOptions, UniversalStorage } from '../../core/user' +import { CookieStorage, UniversalStorage } from '../../core/storage' let cookieOptions: jar.CookieAttributes | undefined function getCookieOptions(): jar.CookieAttributes { @@ -97,10 +97,7 @@ function referrerId( ): void { const storage = new UniversalStorage<{ 's:context.referrer': Ad - }>( - disablePersistance ? [] : ['cookie'], - getAvailableStorageOptions(getCookieOptions()) - ) + }>(disablePersistance ? [] : [new CookieStorage(getCookieOptions())]) const stored = storage.get('s:context.referrer') let ad: Ad | undefined | null = ads(query)